diff --git a/falyx/__init__.py b/falyx/__init__.py
index 20019b5..b60e1d2 100644
--- a/falyx/__init__.py
+++ b/falyx/__init__.py
@@ -1,7 +1,6 @@
-"""
-Falyx CLI Framework
+"""Falyx CLI Framework
-Copyright (c) 2025 rtj.dev LLC.
+Copyright (c) 2026 rtj.dev LLC.
Licensed under the MIT License. See LICENSE file for details.
"""
diff --git a/falyx/__main__.py b/falyx/__main__.py
index d3737a9..7b05393 100644
--- a/falyx/__main__.py
+++ b/falyx/__main__.py
@@ -1,20 +1,18 @@
-"""
-Falyx CLI Framework
+"""Falyx CLI Framework
-Copyright (c) 2025 rtj.dev LLC.
+Copyright (c) 2026 rtj.dev LLC.
Licensed under the MIT License. See LICENSE file for details.
"""
import asyncio
import os
import sys
-from argparse import ArgumentParser, Namespace, _SubParsersAction
from pathlib import Path
from typing import Any
from falyx.config import loader
from falyx.falyx import Falyx
-from falyx.parser import CommandArgumentParser, get_root_parser, get_subparsers
+from falyx.parser import CommandArgumentParser
def find_falyx_config() -> Path | None:
@@ -49,71 +47,39 @@ def init_config(parser: CommandArgumentParser) -> None:
)
-def init_callback(args: Namespace) -> None:
- """Callback for the init command."""
- if args.command == "init":
- from falyx.init import init_project
+def build_bootstrap_falyx() -> Falyx:
+ from falyx.init import init_global, init_project
- init_project(args.name)
- elif args.command == "init_global":
- from falyx.init import init_global
+ flx = Falyx()
- init_global()
-
-
-def get_parsers() -> tuple[ArgumentParser, _SubParsersAction]:
- root_parser: ArgumentParser = get_root_parser()
- subparsers = get_subparsers(root_parser)
- init_parser = subparsers.add_parser(
- "init",
- help="Initialize a new Falyx project",
- description="Create a new Falyx project with mock configuration files.",
- epilog="If no name is provided, the current directory will be used.",
+ flx.add_command(
+ "I",
+ "Initialize a new Falyx project",
+ init_project,
+ aliases=["init"],
+ argument_config=init_config,
+ help_epilog="If no name is provided, the current directory will be used.",
)
- init_parser.add_argument(
- "name",
- type=str,
- help="Name of the new Falyx project",
- default=".",
- nargs="?",
+ flx.add_command(
+ "G",
+ "Initialize Falyx global configuration",
+ init_global,
+ aliases=["init-global"],
+ help_text="Create a global Falyx configuration at ~/.config/falyx/.",
)
- subparsers.add_parser(
- "init-global",
- help="Initialize Falyx global configuration",
- description="Create a global Falyx configuration at ~/.config/falyx/.",
- )
- return root_parser, subparsers
+ return flx
+
+
+def build_falyx() -> Falyx:
+ bootstrap_path = bootstrap()
+ if bootstrap_path:
+ return loader(bootstrap_path)
+ return build_bootstrap_falyx()
def main() -> Any:
- bootstrap_path = bootstrap()
- if not bootstrap_path:
- from falyx.init import init_global, init_project
-
- flx: Falyx = Falyx()
- flx.add_command(
- "I",
- "Initialize a new Falyx project",
- init_project,
- aliases=["init"],
- argument_config=init_config,
- help_epilog="If no name is provided, the current directory will be used.",
- )
- flx.add_command(
- "G",
- "Initialize Falyx global configuration",
- init_global,
- aliases=["init-global"],
- help_text="Create a global Falyx configuration at ~/.config/falyx/.",
- )
- else:
- flx = loader(bootstrap_path)
-
- root_parser, subparsers = get_parsers()
-
- return asyncio.run(
- flx.run(root_parser=root_parser, subparsers=subparsers, callback=init_callback)
- )
+ flx = build_falyx()
+ return asyncio.run(flx.run())
if __name__ == "__main__":
diff --git a/falyx/action/__init__.py b/falyx/action/__init__.py
index bcce9f4..6a552ec 100644
--- a/falyx/action/__init__.py
+++ b/falyx/action/__init__.py
@@ -1,7 +1,6 @@
-"""
-Falyx CLI Framework
+"""Falyx CLI Framework
-Copyright (c) 2025 rtj.dev LLC.
+Copyright (c) 2026 rtj.dev LLC.
Licensed under the MIT License. See LICENSE file for details.
"""
diff --git a/falyx/action/action.py b/falyx/action/action.py
index dc67180..d4714f2 100644
--- a/falyx/action/action.py
+++ b/falyx/action/action.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines `Action`, the core atomic unit in the Falyx CLI framework, used to wrap and
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Defines `Action`, the core atomic unit in the Falyx CLI framework, used to wrap and
execute a single callable or coroutine with structured lifecycle support.
An `Action` is the simplest building block in Falyx's execution model, enabling
@@ -50,8 +49,7 @@ from falyx.utils import ensure_async
class Action(BaseAction):
- """
- Action wraps a simple function or coroutine into a standard executable unit.
+ """Action wraps a simple function or coroutine into a standard executable unit.
It supports:
- Optional retry logic.
@@ -148,8 +146,8 @@ class Action(BaseAction):
self.enable_retry()
def get_infer_target(self) -> tuple[Callable[..., Any], None]:
- """
- Returns the callable to be used for argument inference.
+ """Returns the callable to be used for argument inference.
+
By default, it returns the action itself.
"""
return self.action, None
@@ -208,3 +206,34 @@ class Action(BaseAction):
f"retry={self.retry_policy.enabled}, "
f"rollback={self.rollback is not None})"
)
+
+ def _copy_hooks_without_retry(self) -> HookManager:
+ """Create a copy of the current hooks, excluding any retry handlers."""
+ new_hooks = HookManager()
+ for hook_type, hooks in self.hooks._hooks.items():
+ for hook in hooks:
+ owner = getattr(hook, "__self__", None)
+ if not isinstance(owner, RetryHandler):
+ new_hooks.register(hook_type, hook)
+ return new_hooks
+
+ def clone(self) -> Action:
+ """Create a copy of this Action with the same configuration."""
+ new_action = Action(
+ name=self.name,
+ action=self._action,
+ rollback=self._rollback,
+ args=self.args,
+ kwargs=self.kwargs,
+ hooks=self._copy_hooks_without_retry(),
+ inject_last_result=self.inject_last_result,
+ inject_into=self.inject_into,
+ never_prompt=self.local_never_prompt,
+ retry=self.retry_policy.enabled,
+ retry_policy=self.retry_policy.model_copy(deep=True),
+ spinner_message=self.spinner_message,
+ spinner_type=self.spinner_type,
+ spinner_style=self.spinner_style,
+ spinner_speed=self.spinner_speed,
+ )
+ return new_action
diff --git a/falyx/action/action_factory.py b/falyx/action/action_factory.py
index e9aae09..b7b5711 100644
--- a/falyx/action/action_factory.py
+++ b/falyx/action/action_factory.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines `ActionFactory`, a dynamic Falyx Action that defers the construction of its
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Defines `ActionFactory`, a dynamic Falyx Action that defers the construction of its
underlying logic to runtime using a user-defined factory function.
This pattern is useful when the specific Action to execute cannot be determined until
@@ -31,6 +30,8 @@ Example:
inject_last_result=True,
)
"""
+from __future__ import annotations
+
from typing import Any, Callable
from rich.tree import Tree
@@ -46,8 +47,7 @@ from falyx.utils import ensure_async
class ActionFactory(BaseAction):
- """
- Dynamically creates and runs another Action at runtime using a factory function.
+ """Dynamically creates and runs another Action at runtime using a factory function.
This is useful for generating context-specific behavior (e.g., dynamic HTTPActions)
where the structure of the next action depends on runtime values.
@@ -176,3 +176,16 @@ class ActionFactory(BaseAction):
f"factory={self._factory.__name__ if hasattr(self._factory, '__name__') else type(self._factory).__name__}, "
f"args={self.args!r}, kwargs={self.kwargs!r})"
)
+
+ def clone(self) -> ActionFactory:
+ """Return a copy of this ActionFactory with the same configuration."""
+ return ActionFactory(
+ name=self.name,
+ factory=self._factory,
+ inject_last_result=self.inject_last_result,
+ inject_into=self.inject_into,
+ args=self.args,
+ kwargs=self.kwargs,
+ preview_args=self.preview_args,
+ preview_kwargs=self.preview_kwargs,
+ )
diff --git a/falyx/action/action_group.py b/falyx/action/action_group.py
index df8eaef..00fc057 100644
--- a/falyx/action/action_group.py
+++ b/falyx/action/action_group.py
@@ -1,7 +1,6 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines `ActionGroup`, a Falyx Action that executes multiple sub-actions concurrently
-using asynchronous parallelism.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Defines `ActionGroup`, a Falyx Action that executes multiple sub-actions concurrently
+using asynchronous concurrency.
`ActionGroup` is designed for workflows where several independent actions can run
simultaneously to improve responsiveness and reduce latency. It ensures robust error
@@ -9,7 +8,7 @@ isolation, shared result tracking, and full lifecycle hook integration while pre
Falyx's introspectability and chaining capabilities.
Key Features:
-- Executes all actions in parallel via `asyncio.gather`
+- Executes all actions concurrently via `asyncio.gather`
- Aggregates results as a list of `(name, result)` tuples
- Collects and reports multiple errors without interrupting execution
- Compatible with `SharedContext`, `OptionsManager`, and `last_result` injection
@@ -27,11 +26,11 @@ Raises:
Example:
ActionGroup(
- name="ParallelChecks",
+ name="ConcurrentChecks",
actions=[Action(...), Action(...), ChainedAction(...)],
)
-This module complements `ChainedAction` by offering breadth-wise (parallel) execution
+This module complements `ChainedAction` by offering breadth-wise (concurrent) execution
as opposed to depth-wise (sequential) execution.
"""
import asyncio
@@ -54,14 +53,13 @@ from falyx.themes.colors import OneColors
class ActionGroup(BaseAction, ActionListMixin):
- """
- ActionGroup executes multiple actions concurrently in parallel.
+ """ActionGroup executes multiple actions concurrently.
It is ideal for independent tasks that can be safely run simultaneously,
improving overall throughput and responsiveness of workflows.
Core features:
- - Parallel execution of all contained actions.
+ - Concurrent execution of all contained actions.
- Shared last_result injection across all actions if configured.
- Aggregated collection of individual results as (name, result) pairs.
- Hook lifecycle support (before, on_success, on_error, after, on_teardown).
@@ -75,7 +73,7 @@ class ActionGroup(BaseAction, ActionListMixin):
Best used for:
- Batch processing multiple independent tasks.
- - Reducing latency for workflows with parallelizable steps.
+ - Reducing latency for workflows with concurrent steps.
- Isolating errors while maximizing successful execution.
Args:
@@ -173,7 +171,7 @@ class ActionGroup(BaseAction, ActionListMixin):
combined_args = args + self.args
combined_kwargs = {**self.kwargs, **kwargs}
- shared_context = SharedContext(name=self.name, action=self, is_parallel=True)
+ shared_context = SharedContext(name=self.name, action=self, is_concurrent=True)
if self.shared_context:
shared_context.set_shared_result(self.shared_context.last_result())
updated_kwargs = self._maybe_inject_last_result(combined_kwargs)
@@ -229,7 +227,7 @@ class ActionGroup(BaseAction, ActionListMixin):
action.register_hooks_recursively(hook_type, hook)
async def preview(self, parent: Tree | None = None):
- label = [f"[{OneColors.MAGENTA_b}]⏩ ActionGroup (parallel)[/] '{self.name}'"]
+ label = [f"[{OneColors.MAGENTA_b}]⏩ ActionGroup (concurrent)[/] '{self.name}'"]
if self.inject_last_result:
label.append(f" [dim](receives '{self.inject_into}')[/dim]")
tree = parent.add("".join(label)) if parent else Tree("".join(label))
@@ -246,3 +244,24 @@ class ActionGroup(BaseAction, ActionListMixin):
f"inject_last_result={self.inject_last_result}, "
f"inject_into={self.inject_into})"
)
+
+ def clone(self):
+ """Return a copy of this ActionGroup with the same configuration."""
+ cloned_actions = [
+ action.clone() if isinstance(action, BaseAction) else action
+ for action in self.actions
+ ]
+ return ActionGroup(
+ name=self.name,
+ actions=cloned_actions,
+ args=self.args,
+ kwargs=self.kwargs,
+ hooks=self.hooks.copy(),
+ inject_last_result=self.inject_last_result,
+ inject_into=self.inject_into,
+ never_prompt=self.local_never_prompt,
+ spinner_message=self.spinner_message,
+ spinner_type=self.spinner_type,
+ spinner_style=self.spinner_style,
+ spinner_speed=self.spinner_speed,
+ )
diff --git a/falyx/action/action_mixins.py b/falyx/action/action_mixins.py
index a0e237d..6784265 100644
--- a/falyx/action/action_mixins.py
+++ b/falyx/action/action_mixins.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Provides reusable mixins for managing collections of `BaseAction` instances
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Provides reusable mixins for managing collections of `BaseAction` instances
within composite Falyx actions such as `ActionGroup` or `ChainedAction`.
The primary export, `ActionListMixin`, encapsulates common functionality for
diff --git a/falyx/action/action_types.py b/falyx/action/action_types.py
index 69e5052..83b6ba6 100644
--- a/falyx/action/action_types.py
+++ b/falyx/action/action_types.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines strongly-typed enums used throughout the Falyx CLI framework for
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Defines strongly-typed enums used throughout the Falyx CLI framework for
representing common structured values like file formats, selection return types,
and confirmation modes.
@@ -28,8 +27,7 @@ from enum import Enum
class FileType(Enum):
- """
- Represents supported file types for reading and writing in Falyx Actions.
+ """Represents supported file types for reading and writing in Falyx Actions.
Used by `LoadFileAction` and `SaveFileAction` to determine how to parse or
serialize file content. Includes alias resolution for common extensions like
@@ -91,8 +89,7 @@ class FileType(Enum):
class SelectionReturnType(Enum):
- """
- Controls what is returned from a `SelectionAction` when using a selection map.
+ """Controls what is returned from a `SelectionAction` when using a selection map.
Determines how the user's choice(s) from a `dict[str, SelectionOption]` are
transformed and returned by the action.
@@ -145,8 +142,7 @@ class SelectionReturnType(Enum):
class ConfirmType(Enum):
- """
- Enum for defining prompt styles in confirmation dialogs.
+ """Enum for defining prompt styles in confirmation dialogs.
Used by confirmation actions to control user input behavior and available choices.
diff --git a/falyx/action/base_action.py b/falyx/action/base_action.py
index 83ace00..4883a93 100644
--- a/falyx/action/base_action.py
+++ b/falyx/action/base_action.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Core action system for Falyx.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Core action system for Falyx.
This module defines the building blocks for executable actions and workflows,
providing a structured way to compose, execute, recover, and manage sequences of
@@ -14,13 +13,13 @@ Core guarantees:
- Consistent timing and execution context tracking for each run.
- Unified, predictable result handling and error propagation.
- Optional last_result injection to enable flexible, data-driven workflows.
-- Built-in support for retries, rollbacks, parallel groups, chaining, and fallback
+- Built-in support for retries, rollbacks, concurrent groups, chaining, and fallback
recovery.
Key components:
- Action: wraps a function or coroutine into a standard executable unit.
- ChainedAction: runs actions sequentially, optionally injecting last results.
-- ActionGroup: runs actions in parallel and gathers results.
+- ActionGroup: runs actions concurrently and gathers results.
- ProcessAction: executes CPU-bound functions in a separate process.
- LiteralInputAction: injects static values into workflows.
- FallbackAction: gracefully recovers from failures or missing data.
@@ -46,8 +45,7 @@ from falyx.themes import OneColors
class BaseAction(ABC):
- """
- Base class for actions. Actions can be simple functions or more
+ """Base class for actions. Actions can be simple functions or more
complex actions like `ChainedAction` or `ActionGroup`. They can also
be run independently or as part of Falyx.
@@ -115,8 +113,8 @@ class BaseAction(ABC):
@abstractmethod
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
- """
- Returns the callable to be used for argument inference.
+ """Returns the callable to be used for argument inference.
+
By default, it returns None.
"""
raise NotImplementedError("get_infer_target must be implemented by subclasses")
@@ -127,12 +125,16 @@ class BaseAction(ABC):
def set_shared_context(self, shared_context: SharedContext) -> None:
self.shared_context = shared_context
- def get_option(self, option_name: str, default: Any = None) -> Any:
- """
- Resolve an option from the OptionsManager if present, otherwise use the fallback.
- """
+ def get_option(
+ self,
+ option_name: str,
+ default: Any = None,
+ *,
+ namespace_name: str = "default",
+ ) -> Any:
+ """Resolve an option from the OptionsManager if present, else default."""
if self.options_manager:
- return self.options_manager.get(option_name, default)
+ return self.options_manager.get(option_name, default, namespace_name)
return default
@property
@@ -146,7 +148,12 @@ class BaseAction(ABC):
def never_prompt(self) -> bool:
if self._never_prompt is not None:
return self._never_prompt
- return self.get_option("never_prompt", False)
+ return self.get_option("never_prompt", False, namespace_name="root")
+
+ @property
+ def local_never_prompt(self) -> bool | None:
+ """Return the local never_prompt setting, which may be None if not explicitly set."""
+ return self._never_prompt
@property
def spinner_manager(self):
@@ -158,8 +165,8 @@ class BaseAction(ABC):
def prepare(
self, shared_context: SharedContext, options_manager: OptionsManager | None = None
) -> BaseAction:
- """
- Prepare the action specifically for sequential (ChainedAction) execution.
+ """Prepare the action specifically for sequential (ChainedAction) execution.
+
Can be overridden for chain-specific logic.
"""
self.set_shared_context(shared_context)
@@ -185,3 +192,8 @@ class BaseAction(ABC):
def __repr__(self) -> str:
return str(self)
+
+ @abstractmethod
+ def clone(self) -> BaseAction:
+ """Return a copy of this action. Must be implemented by subclasses."""
+ return self
diff --git a/falyx/action/chained_action.py b/falyx/action/chained_action.py
index 1538f97..a74d069 100644
--- a/falyx/action/chained_action.py
+++ b/falyx/action/chained_action.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines `ChainedAction`, a core Falyx construct for executing a sequence of actions
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Defines `ChainedAction`, a core Falyx construct for executing a sequence of actions
in strict order, optionally injecting results from previous steps into subsequent ones.
`ChainedAction` is designed for linear workflows where each step may depend on
@@ -86,8 +85,7 @@ from falyx.themes import OneColors
class ChainedAction(BaseAction, ActionListMixin):
- """
- ChainedAction executes a sequence of actions one after another.
+ """ChainedAction executes a sequence of actions one after another.
Features:
- Supports optional automatic last_result injection (auto_inject).
@@ -117,6 +115,7 @@ class ChainedAction(BaseAction, ActionListMixin):
name: str,
actions: (
Sequence[BaseAction | Callable[..., Any] | Callable[..., Awaitable[Any]]]
+ | Any
| None
) = None,
*,
@@ -276,8 +275,7 @@ class ChainedAction(BaseAction, ActionListMixin):
async def _rollback(
self, rollback_stack: list[tuple[Action, tuple[Any, ...], dict[str, Any]]]
):
- """
- Roll back all executed actions in reverse order.
+ """Roll back all executed actions in reverse order.
Rollbacks run even if a fallback recovered from failure,
ensuring consistent undo of all side effects.
@@ -320,3 +318,26 @@ class ChainedAction(BaseAction, ActionListMixin):
f"args={self.args!r}, kwargs={self.kwargs!r}, "
f"auto_inject={self.auto_inject}, return_list={self.return_list})"
)
+
+ def clone(self) -> ChainedAction:
+ """Create a copy of this ChainedAction with the same configuration."""
+ cloned_actions = [
+ action.clone() if isinstance(action, BaseAction) else action
+ for action in self.actions
+ ]
+ return ChainedAction(
+ name=self.name,
+ actions=cloned_actions,
+ args=self.args,
+ kwargs=self.kwargs,
+ hooks=self.hooks.copy(),
+ inject_last_result=self.inject_last_result,
+ inject_into=self.inject_into,
+ auto_inject=self.auto_inject,
+ return_list=self.return_list,
+ never_prompt=self.local_never_prompt,
+ spinner_message=self.spinner_message,
+ spinner_type=self.spinner_type,
+ spinner_style=self.spinner_style,
+ spinner_speed=self.spinner_speed,
+ )
diff --git a/falyx/action/confirm_action.py b/falyx/action/confirm_action.py
index 228b5c2..10750e6 100644
--- a/falyx/action/confirm_action.py
+++ b/falyx/action/confirm_action.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines `ConfirmAction`, a Falyx Action that prompts the user for confirmation
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Defines `ConfirmAction`, a Falyx Action that prompts the user for confirmation
before continuing execution.
`ConfirmAction` supports a wide range of confirmation strategies, including:
@@ -62,8 +61,7 @@ from falyx.validators import word_validator, words_validator
class ConfirmAction(BaseAction):
- """
- Action to confirm an operation with the user.
+ """Action to confirm an operation with the user.
There are several ways to confirm an action, such as using a simple
yes/no prompt. You can also use a confirmation type that requires the user
@@ -91,14 +89,13 @@ class ConfirmAction(BaseAction):
prompt_message: str = "Confirm?",
confirm_type: ConfirmType | str = ConfirmType.YES_NO,
prompt_session: PromptSession | None = None,
- never_prompt: bool = False,
+ never_prompt: bool | None = False,
word: str = "CONFIRM",
return_last_result: bool = False,
inject_last_result: bool = True,
inject_into: str = "last_result",
):
- """
- Initialize the ConfirmAction.
+ """Initialize the ConfirmAction.
Args:
message (str): The confirmation message to display.
@@ -270,3 +267,17 @@ class ConfirmAction(BaseAction):
f"ConfirmAction(name={self.name}, message={self.prompt_message}, "
f"confirm_type={self.confirm_type}, return_last_result={self.return_last_result})"
)
+
+ def clone(self) -> ConfirmAction:
+ """Return a copy of this ConfirmAction with the same configuration."""
+ return ConfirmAction(
+ name=self.name,
+ prompt_message=self.prompt_message,
+ confirm_type=self.confirm_type,
+ prompt_session=self.prompt_session,
+ never_prompt=self.local_never_prompt,
+ word=self.word,
+ return_last_result=self.return_last_result,
+ inject_last_result=self.inject_last_result,
+ inject_into=self.inject_into,
+ )
diff --git a/falyx/action/fallback_action.py b/falyx/action/fallback_action.py
index d74b291..75aaef8 100644
--- a/falyx/action/fallback_action.py
+++ b/falyx/action/fallback_action.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines `FallbackAction`, a lightweight recovery Action used within `ChainedAction`
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Defines `FallbackAction`, a lightweight recovery Action used within `ChainedAction`
pipelines to gracefully handle errors or missing results from a preceding step.
When placed immediately after a failing or null-returning Action, `FallbackAction`
@@ -36,6 +35,8 @@ Example:
The `FallbackAction` ensures that even if `MaybeFetchRemoteAction` fails or returns
None, `ProcessDataAction` still receives a usable input.
"""
+from __future__ import annotations
+
from functools import cached_property
from typing import Any
@@ -46,8 +47,7 @@ from falyx.themes import OneColors
class FallbackAction(Action):
- """
- FallbackAction provides a default value if the previous action failed or
+ """FallbackAction provides a default value if the previous action failed or
returned None.
It injects the last result and checks:
@@ -85,3 +85,7 @@ class FallbackAction(Action):
def __str__(self) -> str:
return f"FallbackAction(fallback={self.fallback!r})"
+
+ def clone(self) -> FallbackAction:
+ """Return a copy of this FallbackAction with the same fallback value."""
+ return FallbackAction(fallback=self.fallback)
diff --git a/falyx/action/http_action.py b/falyx/action/http_action.py
index adb5e14..60ee22f 100644
--- a/falyx/action/http_action.py
+++ b/falyx/action/http_action.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines an Action subclass for making HTTP requests using aiohttp within Falyx workflows.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Defines `HTTPAction` for making HTTP requests using aiohttp.
Features:
- Automatic reuse of aiohttp.ClientSession via SharedContext
@@ -8,6 +7,9 @@ Features:
- Retry integration and last_result injection
- Clean resource teardown using hooks
"""
+from __future__ import annotations
+
+from copy import deepcopy
from typing import Any
import aiohttp
@@ -32,8 +34,7 @@ async def close_shared_http_session(context: ExecutionContext) -> None:
class HTTPAction(Action):
- """
- An Action for executing HTTP requests using aiohttp with shared session reuse.
+ """An Action for executing HTTP requests using aiohttp with shared session reuse.
This action integrates seamlessly into Falyx pipelines, with automatic session
management, result injection, and lifecycle hook support. It is ideal for CLI-driven
@@ -82,6 +83,7 @@ class HTTPAction(Action):
spinner_type: str = "dots",
spinner_style: str = OneColors.CYAN,
spinner_speed: float = 1.0,
+ never_prompt: bool | None = None,
):
self.method = method.upper()
self.url = url
@@ -105,6 +107,7 @@ class HTTPAction(Action):
spinner_type=spinner_type,
spinner_style=spinner_style,
spinner_speed=spinner_speed,
+ never_prompt=never_prompt,
)
async def _request(self, *_, **__) -> dict[str, Any]:
@@ -167,3 +170,26 @@ class HTTPAction(Action):
f"data={self.data!r}, retry={self.retry_policy.enabled}, "
f"inject_last_result={self.inject_last_result})"
)
+
+ def clone(self) -> HTTPAction:
+ """Return a copy of this HTTPAction with the same configuration."""
+ return HTTPAction(
+ name=self.name,
+ method=self.method,
+ url=self.url,
+ headers=self.headers.copy() if self.headers else None,
+ params=self.params.copy() if self.params else None,
+ json=deepcopy(self.json),
+ data=self.data,
+ hooks=self._copy_hooks_without_retry(),
+ inject_last_result=self.inject_last_result,
+ inject_into=self.inject_into,
+ retry=self.retry_policy.enabled,
+ retry_policy=self.retry_policy.model_copy(deep=True),
+ spinner=False,
+ spinner_message=self.spinner_message,
+ spinner_type=self.spinner_type,
+ spinner_style=self.spinner_style,
+ spinner_speed=self.spinner_speed,
+ never_prompt=self.local_never_prompt,
+ )
diff --git a/falyx/action/io_action.py b/falyx/action/io_action.py
index d208715..6fc56ba 100644
--- a/falyx/action/io_action.py
+++ b/falyx/action/io_action.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-BaseIOAction: A base class for stream- or buffer-based IO-driven Actions.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""BaseIOAction: A base class for stream- or buffer-based IO-driven Actions.
This module defines `BaseIOAction`, a specialized variant of `BaseAction`
that interacts with standard input and output, enabling command-line pipelines,
@@ -15,6 +14,8 @@ Features:
Common usage includes shell-like filters, input transformers, or any tool that
needs to consume input from another process or pipeline.
"""
+from __future__ import annotations
+
import asyncio
import sys
from typing import Any, Callable
@@ -29,8 +30,7 @@ from falyx.themes import OneColors
class BaseIOAction(BaseAction):
- """
- Base class for IO-driven Actions that operate on stdin/stdout input streams.
+ """Base class for IO-driven Actions that operate on stdin/stdout input streams.
Designed for use in shell pipelines or programmatic workflows that pass data
through chained commands. It handles reading input, transforming it, and
@@ -170,3 +170,12 @@ class BaseIOAction(BaseAction):
parent.add("".join(label))
else:
self.console.print(Tree("".join(label)))
+
+ def clone(self) -> BaseIOAction:
+ """Create a copy of this BaseIOAction with the same configuration."""
+ return self.__class__(
+ name=self.name,
+ hooks=self.hooks.copy(),
+ mode=self.mode,
+ inject_last_result=self.inject_last_result,
+ )
diff --git a/falyx/action/literal_input_action.py b/falyx/action/literal_input_action.py
index 552033f..20019f2 100644
--- a/falyx/action/literal_input_action.py
+++ b/falyx/action/literal_input_action.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines `LiteralInputAction`, a lightweight Falyx Action that injects a static,
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Defines `LiteralInputAction`, a lightweight Falyx Action that injects a static,
predefined value into a `ChainedAction` workflow.
This Action is useful for embedding literal values (e.g., strings, numbers,
@@ -43,8 +42,7 @@ from falyx.themes import OneColors
class LiteralInputAction(Action):
- """
- LiteralInputAction injects a static value into a ChainedAction.
+ """LiteralInputAction injects a static value into a ChainedAction.
This allows embedding hardcoded values mid-pipeline, useful when:
- Providing default or fallback inputs.
@@ -78,3 +76,7 @@ class LiteralInputAction(Action):
def __str__(self) -> str:
return f"LiteralInputAction(value={self.value!r})"
+
+ def clone(self) -> LiteralInputAction:
+ """Create a copy of this LiteralInputAction with the same value."""
+ return LiteralInputAction(self.value)
diff --git a/falyx/action/load_file_action.py b/falyx/action/load_file_action.py
index a4c00fb..dabaeeb 100644
--- a/falyx/action/load_file_action.py
+++ b/falyx/action/load_file_action.py
@@ -1,7 +1,6 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines `LoadFileAction`, a Falyx Action for reading and parsing the contents of a file
-at runtime in a structured, introspectable, and lifecycle-aware manner.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Defines `LoadFileAction`, a Falyx Action for reading and parsing the contents of a
+file at runtime in a structured, introspectable, and lifecycle-aware manner.
This action supports multiple common file types—including plain text, structured data
formats (JSON, YAML, TOML), tabular formats (CSV, TSV), XML, and raw Path objects—
@@ -36,6 +35,8 @@ This module is a foundational building block for file-driven CLI workflows in Fa
It is often paired with `SaveFileAction`, `SelectionAction`, or `ConfirmAction` for
robust and interactive pipelines.
"""
+from __future__ import annotations
+
import csv
import json
import xml.etree.ElementTree as ET
@@ -57,8 +58,7 @@ from falyx.themes import OneColors
class LoadFileAction(BaseAction):
- """
- LoadFileAction loads and parses the contents of a file at runtime.
+ """LoadFileAction loads and parses the contents of a file at runtime.
This action supports multiple common file formats—including plain text, JSON,
YAML, TOML, XML, CSV, and TSV—and returns a parsed representation of the file.
@@ -187,6 +187,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:
@@ -243,7 +244,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)
@@ -262,3 +263,14 @@ class LoadFileAction(BaseAction):
def __str__(self) -> str:
return f"LoadFileAction(file_path={self.file_path}, file_type={self.file_type})"
+
+ def clone(self) -> LoadFileAction:
+ """Create a copy of this LoadFileAction with the same configuration."""
+ return LoadFileAction(
+ name=self.name,
+ file_path=self.file_path,
+ file_type=self.file_type,
+ encoding=self.encoding,
+ inject_last_result=self.inject_last_result,
+ inject_into=self.inject_into,
+ )
diff --git a/falyx/action/menu_action.py b/falyx/action/menu_action.py
index 5174966..b793985 100644
--- a/falyx/action/menu_action.py
+++ b/falyx/action/menu_action.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines `MenuAction`, a one-shot, interactive menu-style Falyx Action that presents
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Defines `MenuAction`, a one-shot, interactive menu-style Falyx Action that presents
a set of labeled options to the user and executes the corresponding action based on
their selection.
@@ -37,6 +36,8 @@ Example:
This module is ideal for enabling structured, discoverable, and declarative
menus in both interactive and programmatic CLI automation.
"""
+from __future__ import annotations
+
from typing import Any
from prompt_toolkit import PromptSession
@@ -57,8 +58,7 @@ from falyx.utils import chunks
class MenuAction(BaseAction):
- """
- MenuAction displays a one-time interactive menu of predefined options,
+ """MenuAction displays a one-time interactive menu of predefined options,
each mapped to a corresponding Action.
Unlike the main Falyx menu system, `MenuAction` is intended for scoped,
@@ -121,7 +121,7 @@ class MenuAction(BaseAction):
inject_last_result: bool = False,
inject_into: str = "last_result",
prompt_session: PromptSession | None = None,
- never_prompt: bool = False,
+ never_prompt: bool | None = False,
include_reserved: bool = True,
show_table: bool = True,
custom_table: Table | None = None,
@@ -247,3 +247,21 @@ class MenuAction(BaseAction):
f"include_reserved={self.include_reserved}, "
f"prompt={'off' if self.never_prompt else 'on'})"
)
+
+ def clone(self) -> MenuAction:
+ """Create a copy of this MenuAction with the same configuration."""
+ return MenuAction(
+ name=self.name,
+ menu_options=self.menu_options.copy(),
+ title=self.title,
+ columns=self.columns,
+ prompt_message=self.prompt_message,
+ default_selection=self.default_selection,
+ inject_last_result=self.inject_last_result,
+ inject_into=self.inject_into,
+ prompt_session=self.prompt_session,
+ never_prompt=self.local_never_prompt,
+ include_reserved=self.include_reserved,
+ show_table=self.show_table,
+ custom_table=self.custom_table,
+ )
diff --git a/falyx/action/process_action.py b/falyx/action/process_action.py
index 7af4405..c7bd421 100644
--- a/falyx/action/process_action.py
+++ b/falyx/action/process_action.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines `ProcessAction`, a Falyx Action that executes a blocking or CPU-bound function
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Defines `ProcessAction`, a Falyx Action that executes a blocking or CPU-bound function
in a separate process using `concurrent.futures.ProcessPoolExecutor`.
This is useful for offloading expensive computations or subprocess-compatible operations
@@ -54,8 +53,7 @@ from falyx.themes import OneColors
class ProcessAction(BaseAction):
- """
- ProcessAction runs a function in a separate process using ProcessPoolExecutor.
+ """ProcessAction runs a function in a separate process using ProcessPoolExecutor.
Features:
- Executes CPU-bound or blocking tasks without blocking the main event loop.
@@ -179,3 +177,21 @@ class ProcessAction(BaseAction):
f"action={getattr(self.action, '__name__', repr(self.action))}, "
f"args={self.args!r}, kwargs={self.kwargs!r})"
)
+
+ def clone(self) -> ProcessAction:
+ """Create a copy of this ProcessAction with the same configuration."""
+ return ProcessAction(
+ name=self.name,
+ action=self.action,
+ args=self.args,
+ kwargs=self.kwargs,
+ hooks=self.hooks.copy(),
+ executor=None,
+ inject_last_result=self.inject_last_result,
+ inject_into=self.inject_into,
+ never_prompt=self.local_never_prompt,
+ spinner_message=self.spinner_message,
+ spinner_type=self.spinner_type,
+ spinner_style=self.spinner_style,
+ spinner_speed=self.spinner_speed,
+ )
diff --git a/falyx/action/process_pool_action.py b/falyx/action/process_pool_action.py
index 2ea3bdf..025d7d8 100644
--- a/falyx/action/process_pool_action.py
+++ b/falyx/action/process_pool_action.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines `ProcessPoolAction`, a parallelized action executor that distributes
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Defines `ProcessPoolAction`, a parallelized action executor that distributes
tasks across multiple processes using Python's `concurrent.futures.ProcessPoolExecutor`.
This module enables structured execution of CPU-bound tasks in parallel while
@@ -37,8 +36,7 @@ from falyx.themes import OneColors
@dataclass
class ProcessTask:
- """
- Represents a callable task with its arguments for parallel execution.
+ """Represents a callable task with its arguments for parallel execution.
This lightweight container is used to queue individual tasks for execution
inside a `ProcessPoolAction`.
@@ -60,10 +58,17 @@ class ProcessTask:
if not callable(self.task):
raise TypeError(f"Expected a callable task, got {type(self.task).__name__}")
+ def copy(self) -> ProcessTask:
+ """Create a copy of this ProcessTask."""
+ return ProcessTask(
+ task=self.task,
+ args=self.args,
+ kwargs=self.kwargs.copy(),
+ )
+
class ProcessPoolAction(BaseAction):
- """
- Executes a set of independent tasks in parallel using a process pool.
+ """Executes a set of independent tasks in parallel using a process pool.
`ProcessPoolAction` is ideal for CPU-bound tasks that benefit from
concurrent execution in separate processes. Each task is wrapped in a
@@ -147,7 +152,7 @@ class ProcessPoolAction(BaseAction):
async def _run(self, *args, **kwargs) -> Any:
if not self.actions:
raise EmptyPoolError(f"[{self.name}] No actions to execute.")
- shared_context = SharedContext(name=self.name, action=self, is_parallel=True)
+ shared_context = SharedContext(name=self.name, action=self, is_concurrent=True)
if self.shared_context:
shared_context.set_shared_result(self.shared_context.last_result())
if self.inject_last_result and self.shared_context:
@@ -233,3 +238,15 @@ class ProcessPoolAction(BaseAction):
f"inject_last_result={self.inject_last_result}, "
f"inject_into={self.inject_into!r})"
)
+
+ def clone(self) -> ProcessPoolAction:
+ """Create a copy of this ProcessPoolAction with the same configuration."""
+ cloned = ProcessPoolAction(
+ name=self.name,
+ actions=[action.copy() for action in self.actions],
+ hooks=self.hooks.copy(),
+ executor=None,
+ inject_last_result=self.inject_last_result,
+ inject_into=self.inject_into,
+ )
+ return cloned
diff --git a/falyx/action/prompt_menu_action.py b/falyx/action/prompt_menu_action.py
index 0d0b211..c7f47ff 100644
--- a/falyx/action/prompt_menu_action.py
+++ b/falyx/action/prompt_menu_action.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines `PromptMenuAction`, a Falyx Action that prompts the user to choose from
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Defines `PromptMenuAction`, a Falyx Action that prompts the user to choose from
a list of labeled options using a single-line prompt input. Each option corresponds
to a `MenuOption` that wraps a description and an executable action.
@@ -11,6 +10,8 @@ or contextual user input flows.
Key Components:
- PromptMenuAction: Inline prompt-driven menu runner
"""
+from __future__ import annotations
+
from typing import Any
from prompt_toolkit import PromptSession
@@ -29,8 +30,7 @@ from falyx.themes import OneColors
class PromptMenuAction(BaseAction):
- """
- Displays a single-line interactive prompt for selecting an option from a menu.
+ """Displays a single-line interactive prompt for selecting an option from a menu.
`PromptMenuAction` is a lightweight alternative to `MenuAction`, offering a more
compact selection interface. Instead of rendering a full table, it displays
@@ -189,3 +189,17 @@ class PromptMenuAction(BaseAction):
f"include_reserved={self.include_reserved}, "
f"prompt={'off' if self.never_prompt else 'on'})"
)
+
+ def clone(self) -> PromptMenuAction:
+ """Create a copy of this PromptMenuAction with the same configuration."""
+ return PromptMenuAction(
+ name=self.name,
+ menu_options=self.menu_options.copy(),
+ prompt_message=self.prompt_message,
+ default_selection=self.default_selection,
+ inject_last_result=self.inject_last_result,
+ inject_into=self.inject_into,
+ prompt_session=self.prompt_session,
+ never_prompt=self.never_prompt,
+ include_reserved=self.include_reserved,
+ )
diff --git a/falyx/action/save_file_action.py b/falyx/action/save_file_action.py
index 0132dc2..a20353e 100644
--- a/falyx/action/save_file_action.py
+++ b/falyx/action/save_file_action.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines `SaveFileAction`, a Falyx Action for writing structured or unstructured data
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Defines `SaveFileAction`, a Falyx Action for writing structured or unstructured data
to a file in a variety of supported formats.
Supports overwrite control, automatic directory creation, and full lifecycle hook
@@ -20,6 +19,8 @@ Common use cases:
- Logging artifacts from batch pipelines
- Exporting config or user input to JSON/YAML for reuse
"""
+from __future__ import annotations
+
import csv
import json
import xml.etree.ElementTree as ET
@@ -41,8 +42,7 @@ from falyx.themes import OneColors
class SaveFileAction(BaseAction):
- """
- Saves data to a file in the specified format.
+ """Saves data to a file in the specified format.
`SaveFileAction` serializes and writes input data to disk using the format
defined by `file_type`. It supports plain text and structured formats like
@@ -91,7 +91,7 @@ class SaveFileAction(BaseAction):
def __init__(
self,
name: str,
- file_path: str,
+ file_path: str | Path | None,
file_type: FileType | str = FileType.TEXT,
mode: Literal["w", "a"] = "w",
encoding: str = "UTF-8",
@@ -100,9 +100,9 @@ class SaveFileAction(BaseAction):
create_dirs: bool = True,
inject_last_result: bool = False,
inject_into: str = "data",
+ never_prompt: bool | None = False,
):
- """
- SaveFileAction allows saving data to a file.
+ """SaveFileAction allows saving data to a file.
Args:
name (str): Name of the action.
@@ -115,9 +115,13 @@ class SaveFileAction(BaseAction):
create_dirs (bool): Whether to create parent directories if they do not exist.
inject_last_result (bool): Whether to inject result from previous action.
inject_into (str): Kwarg name to inject the last result as.
+ never_prompt (bool | None): Whether to never prompt for input.
"""
super().__init__(
- name=name, inject_last_result=inject_last_result, inject_into=inject_into
+ name=name,
+ inject_last_result=inject_last_result,
+ inject_into=inject_into,
+ never_prompt=never_prompt,
)
self._file_path = self._coerce_file_path(file_path)
self._file_type = FileType(file_type)
@@ -294,3 +298,19 @@ class SaveFileAction(BaseAction):
def __str__(self) -> str:
return f"SaveFileAction(file_path={self.file_path}, file_type={self.file_type})"
+
+ def clone(self) -> SaveFileAction:
+ """Create a copy of this SaveFileAction with the same configuration."""
+ return SaveFileAction(
+ name=self.name,
+ file_path=self.file_path,
+ file_type=self.file_type,
+ mode=self.mode,
+ encoding=self.encoding,
+ data=self.data,
+ overwrite=self.overwrite,
+ create_dirs=self.create_dirs,
+ inject_last_result=self.inject_last_result,
+ inject_into=self.inject_into,
+ never_prompt=self.local_never_prompt,
+ )
diff --git a/falyx/action/select_file_action.py b/falyx/action/select_file_action.py
index f59e05d..6b76639 100644
--- a/falyx/action/select_file_action.py
+++ b/falyx/action/select_file_action.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines `SelectFileAction`, a Falyx Action that allows users to select one or more
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Defines `SelectFileAction`, a Falyx Action that allows users to select one or more
files from a target directory and optionally return either their content or path,
parsed based on a selected `FileType`.
@@ -72,8 +71,7 @@ from falyx.themes import OneColors
class SelectFileAction(BaseAction):
- """
- SelectFileAction allows users to select a file(s) from a directory and return:
+ """SelectFileAction allows users to select a file(s) from a directory and return:
- file content (as text, JSON, CSV, etc.)
- or the file path itself.
@@ -115,8 +113,9 @@ class SelectFileAction(BaseAction):
separator: str = ",",
allow_duplicates: bool = False,
prompt_session: PromptSession | None = None,
+ never_prompt: bool | None = False,
):
- super().__init__(name)
+ super().__init__(name, never_prompt=never_prompt)
self.directory = Path(directory).resolve()
self.title = title
self.columns = columns
@@ -185,6 +184,9 @@ class SelectFileAction(BaseAction):
raise ValueError(f"Unsupported return type: {self.return_type}")
except Exception as error:
logger.error("Failed to parse %s: %s", file.name, error)
+ raise ValueError(
+ f"Failed to parse {file.name} as {self.return_type}: {error}"
+ ) from error
return value
def _find_cancel_key(self, options) -> str:
@@ -292,3 +294,22 @@ class SelectFileAction(BaseAction):
f"SelectFileAction(name={self.name!r}, dir={str(self.directory)!r}, "
f"suffix_filter={self.suffix_filter!r}, return_type={self.return_type})"
)
+
+ def clone(self) -> SelectFileAction:
+ """Create a copy of this SelectFileAction with the same configuration."""
+ return SelectFileAction(
+ name=self.name,
+ directory=self.directory,
+ title=self.title,
+ columns=self.columns,
+ prompt_message=self.prompt_message,
+ style=self.style,
+ suffix_filter=self.suffix_filter,
+ return_type=self.return_type,
+ encoding=self.encoding,
+ number_selections=self.number_selections,
+ separator=self.separator,
+ allow_duplicates=self.allow_duplicates,
+ prompt_session=self.prompt_session,
+ never_prompt=self.local_never_prompt,
+ )
diff --git a/falyx/action/selection_action.py b/falyx/action/selection_action.py
index 1047267..ec65385 100644
--- a/falyx/action/selection_action.py
+++ b/falyx/action/selection_action.py
@@ -1,7 +1,6 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines `SelectionAction`, a highly flexible Falyx Action for interactive or headless
-selection from a list or dictionary of user-defined options.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Defines `SelectionAction`, a highly flexible Falyx Action for interactive or
+headless selection from a list or dictionary of user-defined options.
This module powers workflows that require prompting the user for input, selecting
configuration presets, branching execution paths, or collecting multiple values
@@ -31,6 +30,8 @@ Example:
This module is foundational to creating expressive, user-centered CLI experiences
within Falyx while preserving reproducibility and automation friendliness.
"""
+from __future__ import annotations
+
from typing import Any
from prompt_toolkit import PromptSession
@@ -56,9 +57,8 @@ from falyx.themes import OneColors
class SelectionAction(BaseAction):
- """
- A Falyx Action for interactively or programmatically selecting one or more items
- from a list or dictionary of options.
+ """A Falyx Action for interactively or programmatically selecting one or more
+ items from a list or dictionary of options.
`SelectionAction` supports both `list[str]` and `dict[str, SelectionOption]`
inputs. It renders a prompt (unless `never_prompt=True`), validates user input
@@ -90,7 +90,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.
@@ -135,7 +140,7 @@ class SelectionAction(BaseAction):
inject_into: str = "last_result",
return_type: SelectionReturnType | str = "value",
prompt_session: PromptSession | None = None,
- never_prompt: bool = False,
+ never_prompt: bool | None = False,
show_table: bool = True,
):
super().__init__(
@@ -553,3 +558,23 @@ class SelectionAction(BaseAction):
f"return_type={self.return_type!r}, "
f"prompt={'off' if self.never_prompt else 'on'})"
)
+
+ def clone(self) -> SelectionAction:
+ """Create a copy of this SelectionAction with the same configuration."""
+ return SelectionAction(
+ name=self.name,
+ selections=self.selections.copy(),
+ title=self.title,
+ columns=self.columns,
+ prompt_message=self.prompt_message,
+ default_selection=self.default_selection,
+ number_selections=self.number_selections,
+ separator=self.separator,
+ allow_duplicates=self.allow_duplicates,
+ inject_last_result=self.inject_last_result,
+ inject_into=self.inject_into,
+ return_type=self.return_type,
+ prompt_session=self.prompt_session,
+ never_prompt=self.local_never_prompt,
+ show_table=self.show_table,
+ )
diff --git a/falyx/action/shell_action.py b/falyx/action/shell_action.py
index 6b0f899..66eaac2 100644
--- a/falyx/action/shell_action.py
+++ b/falyx/action/shell_action.py
@@ -1,4 +1,4 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Execute shell commands with input substitution."""
from __future__ import annotations
@@ -16,8 +16,7 @@ from falyx.themes import OneColors
class ShellAction(BaseIOAction):
- """
- ShellAction wraps a shell command template for CLI pipelines.
+ """ShellAction wraps a shell command template for CLI pipelines.
This Action takes parsed input (from stdin, literal, or last_result),
substitutes it into the provided shell command template, and executes
@@ -102,3 +101,15 @@ class ShellAction(BaseIOAction):
f"ShellAction(name={self.name!r}, command_template={self.command_template!r},"
f" safe_mode={self.safe_mode})"
)
+
+ def clone(self) -> ShellAction:
+ """Create a copy of this ShellAction with the same configuration."""
+ return ShellAction(
+ name=self.name,
+ command_template=self.command_template,
+ safe_mode=self.safe_mode,
+ mode=self.mode,
+ hooks=self.hooks.copy(),
+ inject_last_result=self.inject_last_result,
+ inject_into=self.inject_into,
+ )
diff --git a/falyx/action/signal_action.py b/falyx/action/signal_action.py
index f9625ba..3e5d3cf 100644
--- a/falyx/action/signal_action.py
+++ b/falyx/action/signal_action.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines `SignalAction`, a lightweight Falyx Action that raises a `FlowSignal`
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Defines `SignalAction`, a lightweight Falyx Action that raises a `FlowSignal`
(such as `BackSignal`, `QuitSignal`, or `BreakChainSignal`) during execution to
alter or exit the CLI flow.
@@ -24,6 +23,8 @@ Use Cases:
Example:
SignalAction("ExitApp", QuitSignal(), hooks=my_hook_manager)
"""
+from __future__ import annotations
+
from rich.tree import Tree
from falyx.action.action import Action
@@ -33,8 +34,7 @@ from falyx.themes import OneColors
class SignalAction(Action):
- """
- A hook-compatible action that raises a control flow signal when invoked.
+ """A hook-compatible action that raises a control flow signal when invoked.
`SignalAction` raises a `FlowSignal` (e.g., `BackSignal`, `QuitSignal`,
`BreakChainSignal`) during execution. It is commonly used to exit menus,
@@ -59,8 +59,7 @@ class SignalAction(Action):
super().__init__(name, action=self.raise_signal, hooks=hooks)
async def raise_signal(self, *args, **kwargs):
- """
- Raises the configured `FlowSignal`.
+ """Raises the configured `FlowSignal`.
This method is called internally by the Falyx runtime and is the core
behavior of the action. All hooks surrounding execution are still triggered.
@@ -74,8 +73,7 @@ class SignalAction(Action):
@signal.setter
def signal(self, value: FlowSignal):
- """
- Validates that the provided value is a `FlowSignal`.
+ """Validates that the provided value is a `FlowSignal`.
Raises:
TypeError: If `value` is not an instance of `FlowSignal`.
@@ -94,3 +92,7 @@ class SignalAction(Action):
tree = parent.add(label) if parent else Tree(label)
if not parent:
self.console.print(tree)
+
+ def clone(self) -> SignalAction:
+ """Creates a copy of this SignalAction with the same configuration."""
+ return SignalAction(name=self.name, signal=self.signal, hooks=self.hooks.copy())
diff --git a/falyx/action/user_input_action.py b/falyx/action/user_input_action.py
index e784495..fc7ac10 100644
--- a/falyx/action/user_input_action.py
+++ b/falyx/action/user_input_action.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines `UserInputAction`, a Falyx Action that prompts the user for input using
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Defines `UserInputAction`, a Falyx Action that prompts the user for input using
Prompt Toolkit and returns the result as a string.
This action is ideal for interactive CLI workflows that require user input mid-pipeline.
@@ -26,6 +25,8 @@ Example:
validator=Validator.from_callable(lambda s: len(s) > 0),
)
"""
+from __future__ import annotations
+
from prompt_toolkit import PromptSession
from prompt_toolkit.validation import Validator
from rich.tree import Tree
@@ -40,8 +41,7 @@ from falyx.themes.colors import OneColors
class UserInputAction(BaseAction):
- """
- Prompts the user for textual input and returns their response.
+ """Prompts the user for textual input and returns their response.
`UserInputAction` uses Prompt Toolkit to gather input with optional validation,
lifecycle hook compatibility, and support for default text. If `inject_last_result`
@@ -134,3 +134,15 @@ class UserInputAction(BaseAction):
def __str__(self):
return f"UserInputAction(name={self.name!r}, prompt={self.prompt!r})"
+
+ def clone(self) -> UserInputAction:
+ """Creates a copy of this UserInputAction with the same configuration."""
+ return UserInputAction(
+ name=self.name,
+ prompt_message=self.prompt_message,
+ default_text=self.default_text,
+ multiline=self.multiline,
+ validator=self.validator,
+ prompt_session=self.prompt_session,
+ inject_last_result=self.inject_last_result,
+ )
diff --git a/falyx/bottom_bar.py b/falyx/bottom_bar.py
index 087b5ff..cb66384 100644
--- a/falyx/bottom_bar.py
+++ b/falyx/bottom_bar.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Provides the `BottomBar` class for managing a customizable bottom status bar in
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Provides the `BottomBar` class for managing a customizable bottom status bar in
Falyx-based CLI applications.
The bottom bar is rendered using `prompt_toolkit` and supports:
@@ -72,6 +71,11 @@ class BottomBar:
self.toggle_keys: list[str] = []
self.key_bindings = key_bindings or KeyBindings()
+ @property
+ def has_items(self) -> bool:
+ """Check if the bottom bar has any registered items."""
+ return bool(self._named_items)
+
@staticmethod
def default_render(label: str, value: Any, fg: str, bg: str, width: int) -> HTML:
return HTML(f"")
@@ -202,7 +206,7 @@ class BottomBar:
label: str,
options: OptionsManager,
option_name: str,
- namespace_name: str = "cli_args",
+ namespace_name: str = "default",
fg: str = OneColors.BLACK,
bg_on: str = OneColors.GREEN,
bg_off: str = OneColors.DARK_RED,
diff --git a/falyx/command.py b/falyx/command.py
index d697abb..adf141e 100644
--- a/falyx/command.py
+++ b/falyx/command.py
@@ -1,19 +1,43 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines the Command class for Falyx CLI.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Command abstraction for the Falyx CLI framework.
-Commands are callable units representing a menu option or CLI task,
-wrapping either a BaseAction or a simple function. They provide:
+This module defines the `Command` class, which represents a single executable
+unit exposed to users via CLI or interactive menu interfaces.
-- Hook lifecycle (before, on_success, on_error, after, on_teardown)
+A `Command` acts as a bridge between:
+- User input (parsed via CommandArgumentParser)
+- Execution logic (encapsulated in Action / BaseAction)
+- Runtime configuration (OptionsManager)
+- Lifecycle hooks (HookManager)
+
+Core Responsibilities:
+- Define command identity (key, aliases, description)
+- Bind an executable action or workflow
+- Configure argument parsing via CommandArgumentParser
+- Separate execution arguments (e.g. retries, confirm) from action arguments
+- Manage lifecycle hooks for command-level execution
+- Provide help, usage, and preview interfaces
- Execution timing and duration tracking
-- Retry logic (single action or recursively through action trees)
- Confirmation prompts and spinner integration
-- Result capturing and summary logging
-- Rich-based preview for CLI display
-Every Command is self-contained, configurable, and plays a critical role
-in building robust interactive menus.
+Execution Model:
+1. CLI input is routed via FalyxParser into a resolved Command
+2. Arguments are parsed via CommandArgumentParser
+3. Parsed values are split into:
+ - positional args
+ - keyword args
+ - execution args (e.g. retries, summary)
+4. Execution occurs via the bound Action with lifecycle hooks applied
+5. Results and context are tracked via ExecutionContext / ExecutionRegistry
+
+Key Concepts:
+- Commands are *user-facing entrypoints*, not execution units themselves
+- Execution is always delegated to an underlying Action or callable
+- Argument parsing is declarative and optional
+- Execution options are handled separately from business logic inputs
+
+This module defines the primary abstraction used by Falyx to expose structured,
+composable workflows as CLI commands.
"""
from __future__ import annotations
@@ -22,17 +46,20 @@ 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
from falyx.action.base_action import BaseAction
from falyx.console import console
-from falyx.context import ExecutionContext
+from falyx.context import ExecutionContext, InvocationContext
from falyx.debug import register_debug_hooks
+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
+from falyx.hooks import spinner_before_hook, spinner_teardown_hook
from falyx.logger import logger
-from falyx.mode import FalyxMode
from falyx.options_manager import OptionsManager
from falyx.parser.command_argument_parser import CommandArgumentParser
from falyx.parser.signature import infer_args_from_func
@@ -46,67 +73,104 @@ from falyx.utils import ensure_async
class Command(BaseModel):
- """
- Represents a selectable command in a Falyx menu system.
+ """Represents a user-invokable command in Falyx.
- A Command wraps an executable action (function, coroutine, or BaseAction)
- and enhances it with:
+ A `Command` encapsulates all metadata, parsing logic, and execution behavior
+ required to expose a callable workflow through the Falyx CLI or interactive
+ menu system.
- - Lifecycle hooks (before, success, error, after, teardown)
- - Retry support (single action or recursive for chained/grouped actions)
- - Confirmation prompts for safe execution
- - Spinner visuals during execution
- - Tagging for categorization and filtering
- - Rich-based CLI previews
+ It is responsible for:
+ - Identifying the command via key and aliases
+ - Binding an executable Action or callable
+ - Parsing user-provided arguments
+ - Managing execution configuration (retries, confirmation, etc.)
+ - Integrating with lifecycle hooks and execution context
+
+ Architecture:
+ - Parsing is delegated to CommandArgumentParser
+ - Execution is delegated to BaseAction / Action
+ - Runtime configuration is managed via OptionsManager
+ - Lifecycle hooks are managed via HookManager
+
+ Argument Handling:
+ - Supports positional and keyword arguments via CommandArgumentParser
+ - Separates execution-specific options (e.g. retries, confirm flags)
+ from action arguments
+ - Returns structured `(args, kwargs, execution_args)` for execution
+
+ Execution Behavior:
+ - Callable via `await command(*args, **kwargs)`
+ - Applies lifecycle hooks:
+ before → on_success/on_error → after → on_teardown
+ - Supports preview mode for dry-run introspection
+ - Supports retry policies and confirmation flows
- Result tracking and summary reporting
- Commands are built to be flexible yet robust, enabling dynamic CLI workflows
- without sacrificing control or reliability.
+ Help & Introspection:
+ - Provides usage, help text, and TLDR examples
+ - Supports both CLI help and interactive menu rendering
+ - Can expose simplified or full help signatures
- Attributes:
- key (str): Primary trigger key for the command.
+ Args:
+ key (str): Primary identifier used to invoke the command.
description (str): Short description for the menu display.
- hidden (bool): Toggles visibility in the menu.
- aliases (list[str]): Alternate keys or phrases.
- action (BaseAction | Callable): The executable logic.
- args (tuple): Static positional arguments.
- kwargs (dict): Static keyword arguments.
- help_text (str): Additional help or guidance text.
- style (str): Rich style for description.
- confirm (bool): Whether to require confirmation before executing.
- confirm_message (str): Custom confirmation prompt.
- preview_before_confirm (bool): Whether to preview before confirming.
- spinner (bool): Whether to show a spinner during execution.
- spinner_message (str): Spinner text message.
- spinner_type (str): Spinner style (e.g., dots, line, etc.).
- spinner_style (str): Color or style of the spinner.
- spinner_speed (float): Speed of the spinner animation.
- hooks (HookManager): Hook manager for lifecycle events.
- retry (bool): Enable retry on failure.
- retry_all (bool): Enable retry across chained or grouped actions.
- retry_policy (RetryPolicy): Retry behavior configuration.
- tags (list[str]): Organizational tags for the command.
- logging_hooks (bool): Whether to attach logging hooks automatically.
- options_manager (OptionsManager): Manages global command-line options.
- arg_parser (CommandArgumentParser): Parses command arguments.
- arguments (list[dict[str, Any]]): Argument definitions for the command.
- argument_config (Callable[[CommandArgumentParser], None] | None): Function to configure arguments
- for the command parser.
- custom_parser (ArgParserProtocol | None): Custom argument parser.
- custom_help (Callable[[], str | None] | None): Custom help message generator.
- auto_args (bool): Automatically infer arguments from the action.
- arg_metadata (dict[str, str | dict[str, Any]]): Metadata for arguments,
- such as help text or choices.
- simple_help_signature (bool): Whether to use a simplified help signature.
- ignore_in_history (bool): Whether to ignore this command in execution history last result.
- program: (str | None): The parent program name.
+ action (BaseAction | Callable[..., Any]):
+ Execution logic for the command.
+ args (tuple, optional): Static positional arguments.
+ kwargs (dict[str, Any], optional): Static keyword arguments.
+ hidden (bool): Whether to hide the command from menus.
+ aliases (list[str], optional): Alternate names for invocation.
+ help_text (str): Help description shown in CLI/menu.
+ help_epilog (str): Additional help content.
+ 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 (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.
+ logging_hooks (bool): Enable debug logging hooks.
+ retry (bool): Enable retry behavior.
+ retry_all (bool): Apply retry to all nested actions.
+ retry_policy (RetryPolicy | None): Retry configuration.
+ arg_parser (CommandArgumentParser | None):
+ Custom argument parser instance.
+ execution_options (frozenset[ExecutionOption], optional):
+ Enabled execution-level options.
+ arguments (list[dict[str, Any]], optional):
+ Declarative argument definitions.
+ argument_config (Callable[[CommandArgumentParser], None] | None):
+ Callback to configure parser.
+ custom_parser (ArgParserProtocol | None):
+ Override parser logic entirely.
+ custom_help (Callable[[], str | None] | None):
+ 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.
+ ignore_in_history (bool):
+ Ignore command for `last_result` in execution history.
+ options_manager (OptionsManager | None):
+ Shared options manager instance.
+ program (str | None): The parent program name.
- Methods:
- __call__(): Executes the command, respecting hooks and retries.
- preview(): Rich tree preview of the command.
- confirmation_prompt(): Formatted prompt for confirmation.
- result: Property exposing the last result.
- log_summary(): Summarizes execution details to the console.
+ Raises:
+ CommandArgumentError: If argument parsing fails.
+ InvalidActionError: If action is not callable or invalid.
+ FalyxError: If command configuration is invalid.
+
+ Notes:
+ - Commands are lightweight wrappers; execution logic belongs in Actions
+ - Argument parsing and execution are intentionally decoupled
+ - Commands are case-insensitive and support alias resolution
"""
key: str
@@ -118,16 +182,16 @@ 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)
+ hooks: HookManager = Field(default_factory=HookManager)
retry: bool = False
retry_all: bool = False
retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
@@ -135,10 +199,13 @@ class Command(BaseModel):
logging_hooks: bool = False
options_manager: OptionsManager = Field(default_factory=OptionsManager)
arg_parser: CommandArgumentParser | None = None
+ execution_options: frozenset[ExecutionOption] = Field(default_factory=frozenset)
arguments: list[dict[str, Any]] = Field(default_factory=list)
argument_config: Callable[[CommandArgumentParser], None] | 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]] = Field(default_factory=dict)
simple_help_signature: bool = False
@@ -149,52 +216,106 @@ class Command(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
- async def parse_args(
- self, raw_args: list[str] | str, from_validate: bool = False
- ) -> tuple[tuple, dict]:
- if callable(self.custom_parser):
+ async def resolve_args(
+ self,
+ raw_args: list[str] | str,
+ from_validate: bool = False,
+ invocation_context: InvocationContext | None = None,
+ ) -> tuple[tuple, dict, dict]:
+ """Parse CLI arguments into execution-ready components.
+
+ This method delegates argument parsing to the configured
+ CommandArgumentParser (if present) and normalizes the result into three
+ distinct groups used during execution:
+
+ - positional arguments (`args`)
+ - keyword arguments (`kwargs`)
+ - execution arguments (`execution_args`)
+
+ Execution arguments represent runtime configuration (e.g. retries,
+ confirmation flags, summary output) and are handled separately from the
+ action's business logic inputs.
+
+ Behavior:
+ - If an argument parser is defined, uses `CommandArgumentParser.parse_args_split()`
+ to resolve and type-coerce all inputs.
+ - If no parser is defined, returns empty args and kwargs.
+ - Supports validation mode (`from_validate=True`) for interactive input,
+ deferring certain errors and resolver execution where applicable.
+ - Handles help/preview signals raised during parsing.
+
+ Args:
+ args (list[str] | str | None): CLI-style argument tokens or a single string.
+ from_validate (bool): Whether parsing is occurring in validation mode
+ (e.g. prompt_toolkit validator). When True, may suppress eager
+ resolution or defer certain errors.
+
+ Returns:
+ tuple:
+ - tuple[Any, ...]: Positional arguments for execution.
+ - dict[str, Any]: Keyword arguments for execution.
+ - dict[str, Any]: Execution-specific arguments (e.g. retries,
+ confirm flags, summary).
+
+ Raises:
+ CommandArgumentError: If argument parsing or validation fails.
+ HelpSignal: If help or TLDR output is triggered during parsing.
+
+ Notes:
+ - Execution arguments are not passed to the underlying Action.
+ - This method is the canonical boundary between CLI parsing and
+ execution semantics.
+ """
+ if self.custom_parser is not None:
+ if not callable(self.custom_parser):
+ raise NotAFalyxError(
+ "custom_parser must be a callable that implements ArgParserProtocol."
+ )
if isinstance(raw_args, str):
try:
raw_args = shlex.split(raw_args)
- except ValueError:
- logger.warning(
- "[Command:%s] Failed to split arguments: %s",
- self.key,
- raw_args,
- )
- return ((), {})
+ except ValueError as error:
+ raise CommandArgumentError(
+ f"[{self.key}] Failed to parse arguments: {error}"
+ ) from error
return self.custom_parser(raw_args)
if isinstance(raw_args, str):
try:
raw_args = shlex.split(raw_args)
- except ValueError:
- logger.warning(
- "[Command:%s] Failed to split arguments: %s",
- self.key,
- raw_args,
- )
- return ((), {})
- if not isinstance(self.arg_parser, CommandArgumentParser):
- logger.warning(
- "[Command:%s] No argument parser configured, using default parsing.",
- self.key,
+ except ValueError as error:
+ raise CommandArgumentError(
+ f"[{self.key}] Failed to parse arguments: {error}"
+ ) from error
+
+ if self.arg_parser is None:
+ raise NotAFalyxError(
+ "Command has no parser configured. "
+ "Provide a custom_parser or CommandArgumentParser."
)
- return ((), {})
+ if not isinstance(self.arg_parser, CommandArgumentParser):
+ raise NotAFalyxError(
+ "arg_parser must be an instance of CommandArgumentParser"
+ )
+
return await self.arg_parser.parse_args_split(
- raw_args, from_validate=from_validate
+ raw_args,
+ from_validate=from_validate,
+ invocation_context=invocation_context,
)
@field_validator("action", mode="before")
@classmethod
- def wrap_callable_as_async(cls, action: Any) -> Any:
+ def _wrap_callable_as_async(cls, action: Any) -> Any:
+ """Ensure the action is an async callable or a BaseAction instance."""
if isinstance(action, BaseAction):
return action
elif callable(action):
return ensure_async(action)
raise TypeError("Action must be a callable or an instance of BaseAction")
- def get_argument_definitions(self) -> list[dict[str, Any]]:
+ def _get_argument_definitions(self) -> list[dict[str, Any]]:
+ """Retrieve the argument definitions for the command."""
if self.arguments:
return self.arguments
elif callable(self.argument_config) and isinstance(
@@ -246,9 +367,15 @@ class Command(BaseModel):
program=self.program,
options_manager=self.options_manager,
)
- for arg_def in self.get_argument_definitions():
+ for arg_def in self._get_argument_definitions():
self.arg_parser.add_argument(*arg_def.pop("flags"), **arg_def)
+ if isinstance(self.arg_parser, CommandArgumentParser) and self.execution_options:
+ self.arg_parser.enable_execution_options(self.execution_options)
+
+ if isinstance(self.arg_parser, CommandArgumentParser):
+ self.arg_parser.set_options_manager(self.options_manager)
+
if self.ignore_in_history and isinstance(self.action, BaseAction):
self.action.ignore_in_history = True
@@ -257,10 +384,58 @@ class Command(BaseModel):
if isinstance(self.action, BaseAction):
self.action.set_options_manager(self.options_manager)
+ async def _handle_prompt_user(self) -> None:
+ """Handle user confirmation prompts based on command configuration and options."""
+ action_never_prompt = None
+ if isinstance(self.action, BaseAction):
+ action_never_prompt = self.action.local_never_prompt
+ if should_prompt_user(
+ confirm=self.confirm,
+ options=self.options_manager,
+ action_never_prompt=action_never_prompt,
+ ):
+ if self.preview_before_confirm:
+ await self.preview()
+ if not await confirm_async(self._confirmation_prompt):
+ logger.info("[Command:%s] Cancelled by user.", self.key)
+ raise CancelSignal(f"[Command:{self.key}] Cancelled by confirmation.")
+
async def __call__(self, *args, **kwargs) -> Any:
- """
- Run the action with full hook lifecycle, timing, error handling,
- confirmation prompts, preview, and spinner integration.
+ """Execute the command's underlying action with lifecycle management.
+
+ This method invokes the bound action (BaseAction or callable) using the
+ provided arguments while applying the full Falyx execution lifecycle.
+
+ Execution Flow:
+ 1. Create an ExecutionContext for tracking inputs, results, and timing
+ 2. Trigger `before` hooks
+ 3. Execute the underlying action
+ 4. Trigger `on_success` or `on_error` hooks
+ 5. Trigger `after` and `on_teardown` hooks
+ 6. Record execution via ExecutionRegistry
+
+ Behavior:
+ - Supports both synchronous and asynchronous actions
+ - Applies retry policies if configured
+ - Integrates with confirmation and execution options via OptionsManager
+ - Propagates exceptions unless recovered by hooks (e.g. retry handlers)
+
+ Args:
+ *args (Any): Positional arguments passed to the action.
+ **kwargs (Any): Keyword arguments passed to the action.
+
+ Returns:
+ Any: Result returned by the underlying action.
+
+ Raises:
+ Exception: Propagates execution errors unless handled by hooks.
+
+ Notes:
+ - This method does not perform argument parsing; inputs are assumed
+ to be pre-processed via `resolve_args`.
+ - Execution options (e.g. retries, confirm) are applied externally
+ via Falyx in OptionsManager before invocation.
+ - Lifecycle hooks are always executed, even in failure cases.
"""
self._inject_options_manager()
combined_args = args + self.args
@@ -273,12 +448,7 @@ class Command(BaseModel):
)
self._context = context
- if should_prompt_user(confirm=self.confirm, options=self.options_manager):
- if self.preview_before_confirm:
- await self.preview()
- if not await confirm_async(self.confirmation_prompt):
- logger.info("[Command:%s] Cancelled by user.", self.key)
- raise CancelSignal(f"[Command:{self.key}] Cancelled by confirmation.")
+ await self._handle_prompt_user()
context.start_timer()
@@ -305,7 +475,7 @@ class Command(BaseModel):
return self._context.result if self._context else None
@property
- def confirmation_prompt(self) -> FormattedText:
+ def _confirmation_prompt(self) -> FormattedText:
"""Generate a styled prompt_toolkit FormattedText confirmation message."""
if self.confirm_message and self.confirm_message != "Are you sure?":
return FormattedText([("class:confirm", self.confirm_message)])
@@ -329,30 +499,71 @@ class Command(BaseModel):
return FormattedText(prompt)
+ def get_option(
+ self,
+ option_name: str,
+ default: Any = None,
+ *,
+ namespace_name: str = "default",
+ ) -> Any:
+ """Resolve an option from the OptionsManager if present, else default."""
+ if self.options_manager:
+ return self.options_manager.get(option_name, default, namespace_name)
+ return default
+
+ @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."""
if not self.arg_parser:
return "No arguments defined."
- command_keys_text = self.arg_parser.get_command_keys_text(plain_text=True)
- options_text = self.arg_parser.get_options_text(plain_text=True)
+ command_keys_text = self.arg_parser.get_command_keys_text()
+ options_text = self.arg_parser.get_options_text()
return f" {command_keys_text:<20} {options_text} "
@property
- def help_signature(self) -> tuple[str, str, str]:
- """Generate a help signature for the command."""
- is_cli_mode = self.options_manager.get("mode") in {
- FalyxMode.RUN,
- FalyxMode.PREVIEW,
- FalyxMode.RUN_ALL,
- FalyxMode.HELP,
- }
+ def help_signature(
+ self,
+ invocation_context: InvocationContext | None = None,
+ ) -> tuple[str, str, str]:
+ """Return a formatted help signature for display.
- program = f"{self.program} run " if is_cli_mode else ""
+ This property provides the core information used to render command help
+ in both CLI and interactive menu modes.
+ The signature consists of:
+ - usage: A formatted usage string (including arguments if defined)
+ - description: A short description of the command
+ - tag: Optional tag or category label (if applicable)
+
+ Behavior:
+ - If a CommandArgumentParser is present, delegates usage generation to
+ the parser (`get_usage()`).
+ - Otherwise, constructs a minimal usage string from the command key.
+ - Honors `simple_help_signature` to produce a condensed representation
+ (e.g. omitting argument details).
+ - Applies styling appropriate for Rich rendering.
+
+ Returns:
+ tuple:
+ - str: Usage string (e.g. "falyx D | deploy [--help] region")
+ - str: Command description
+ - str: Optional tag/category label
+
+ Notes:
+ - This is the primary interface used by help menus, CLI help output,
+ and command listings.
+ - Formatting may vary depending on CLI vs menu mode.
+ """
if self.arg_parser and not self.simple_help_signature:
- usage = f"[{self.style}]{program}[/]{self.arg_parser.get_usage()}"
+ usage = self.arg_parser.get_usage(invocation_context)
description = f"[dim]{self.help_text or self.description}[/dim]"
if self.tags:
tags = f"[dim]Tags: {', '.join(self.tags)}[/dim]"
@@ -365,16 +576,29 @@ class Command(BaseModel):
+ [f"[{self.style}]{alias}[/{self.style}]" for alias in self.aliases]
)
return (
- f"[{self.style}]{program}[/]{command_keys}",
+ f"{command_keys}",
f"[dim]{self.help_text or self.description}[/dim]",
"",
)
def log_summary(self) -> None:
+ """Log a summary of the command execution if context is available."""
if self._context:
self._context.log_summary()
- def render_help(self) -> bool:
+ 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):
output = self.custom_help()
@@ -382,11 +606,24 @@ class Command(BaseModel):
console.print(output)
return True
if isinstance(self.arg_parser, CommandArgumentParser):
- self.arg_parser.render_help()
+ self.arg_parser.render_help(invocation_context)
+ return True
+ return False
+
+ def render_tldr(self, invocation_context: InvocationContext | None = None) -> bool:
+ """Display the TLDR message for the command."""
+ if callable(self.custom_tldr):
+ output = self.custom_tldr()
+ if output:
+ console.print(output)
+ return True
+ if isinstance(self.arg_parser, CommandArgumentParser):
+ self.arg_parser.render_tldr(invocation_context)
return True
return False
async def preview(self) -> None:
+ """Preview the command execution."""
label = f"[{OneColors.GREEN_b}]Command:[/] '{self.key}' — {self.description}"
if hasattr(self.action, "preview") and callable(self.action.preview):
@@ -416,3 +653,360 @@ class Command(BaseModel):
f"Command(key='{self.key}', description='{self.description}' "
f"action='{self.action}')"
)
+
+ @classmethod
+ def build(
+ cls,
+ key: str,
+ description: str,
+ action: BaseAction | Callable[..., Any],
+ *,
+ args: tuple = (),
+ kwargs: dict[str, Any] | None = None,
+ hidden: bool = False,
+ aliases: list[str] | None = None,
+ help_text: str = "",
+ help_epilog: str = "",
+ 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: Style | str = OneColors.CYAN,
+ spinner_speed: float = 1.0,
+ options_manager: OptionsManager | None = None,
+ hooks: HookManager | None = None,
+ before_hooks: list[Callable] | None = None,
+ success_hooks: list[Callable] | None = None,
+ error_hooks: list[Callable] | None = None,
+ after_hooks: list[Callable] | None = None,
+ teardown_hooks: list[Callable] | None = None,
+ tags: list[str] | None = None,
+ logging_hooks: bool = False,
+ retry: bool = False,
+ retry_all: bool = False,
+ retry_policy: RetryPolicy | None = None,
+ arg_parser: CommandArgumentParser | None = None,
+ arguments: list[dict[str, Any]] | None = None,
+ argument_config: Callable[[CommandArgumentParser], None] | None = None,
+ 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,
+ ignore_in_history: bool = False,
+ program: str | None = None,
+ ) -> Command:
+ """Build and configure a `Command` instance from high-level constructor inputs.
+
+ This factory centralizes command construction so callers such as `Falyx` and
+ `CommandRunner` can create fully configured commands through one consistent
+ path. It normalizes optional inputs, validates selected objects, converts
+ execution options into their canonical internal form, and registers any
+ requested command-level hooks.
+
+ In addition to instantiating the `Command`, this method can:
+ - validate and attach an explicit `CommandArgumentParser`
+ - normalize execution options into a `frozenset[ExecutionOption]`
+ - ensure a shared `OptionsManager` is available
+ - attach a custom `HookManager`
+ - register lifecycle hooks for the command
+ - register spinner hooks when spinner support is enabled
+
+ Args:
+ key (str): Primary identifier used to invoke the command.
+ description (str): Short description of the command.
+ action (BaseAction | Callable[..., Any]): Underlying execution logic for
+ the command.
+ args (tuple): Static positional arguments applied to every execution.
+ kwargs (dict[str, Any] | None): Static keyword arguments applied to every
+ execution.
+ hidden (bool): Whether the command should be hidden from menu displays.
+ 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 (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 (Style | str): Spinner style.
+ spinner_speed (float): Spinner speed multiplier.
+ options_manager (OptionsManager | None): Shared options manager for the
+ command and its parser.
+ hooks (HookManager | None): Optional hook manager to assign directly to the
+ command.
+ before_hooks (list[Callable] | None): Hooks registered for the `BEFORE`
+ lifecycle stage.
+ success_hooks (list[Callable] | None): Hooks registered for the
+ `ON_SUCCESS` lifecycle stage.
+ error_hooks (list[Callable] | None): Hooks registered for the `ON_ERROR`
+ lifecycle stage.
+ after_hooks (list[Callable] | None): Hooks registered for the `AFTER`
+ lifecycle stage.
+ teardown_hooks (list[Callable] | None): Hooks registered for the
+ `ON_TEARDOWN` lifecycle stage.
+ tags (list[str] | None): Optional tags used for grouping and filtering.
+ logging_hooks (bool): Whether to enable debug hook logging.
+ retry (bool): Whether retry behavior is enabled.
+ retry_all (bool): Whether retry behavior should be applied recursively.
+ retry_policy (RetryPolicy | None): Retry configuration for the command.
+ arg_parser (CommandArgumentParser | None): Optional explicit argument
+ parser instance.
+ arguments (list[dict[str, Any]] | None): Declarative argument
+ definitions for the command parser.
+ argument_config (Callable[[CommandArgumentParser], None] | None): Callback
+ used to configure the argument parser.
+ execution_options (list[ExecutionOption | str] | None): Execution-level
+ options to enable for the command.
+ custom_parser (ArgParserProtocol | None): Optional custom parser
+ implementation that overrides normal parser behavior.
+ 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 when explicit definitions are not provided.
+ arg_metadata (dict[str, str | dict[str, Any]] | None): Optional metadata
+ used during argument inference.
+ simple_help_signature (bool): Whether to use a simplified help signature.
+ ignore_in_history (bool): Whether to exclude the command from execution
+ history tracking.
+ program (str | None): Parent program name used in help rendering.
+
+ Returns:
+ Command: A fully configured `Command` instance.
+
+ Raises:
+ NotAFalyxError: If `arg_parser` is provided but is not a
+ `CommandArgumentParser` instance.
+ InvalidHookError: If `hooks` is provided but is not a `HookManager` instance.
+
+ Notes:
+ - Execution options supplied as strings are converted to
+ `ExecutionOption` enum values before the command is created.
+ - If no `options_manager` is provided, a new `OptionsManager` is created.
+ - Spinner hooks are registered at build time when `spinner=True`.
+ - This method is the canonical command-construction path used by higher-
+ level APIs such as `Falyx.add_command()` and `CommandRunner.build()`.
+ """
+ if arg_parser and not isinstance(arg_parser, CommandArgumentParser):
+ raise NotAFalyxError(
+ "arg_parser must be an instance of CommandArgumentParser."
+ )
+ arg_parser = arg_parser
+
+ if options_manager and not isinstance(options_manager, OptionsManager):
+ raise NotAFalyxError("options_manager must be an instance of OptionsManager.")
+ options_manager = options_manager or OptionsManager()
+
+ if hooks and not isinstance(hooks, HookManager):
+ raise InvalidHookError("hooks must be an instance of HookManager.")
+ hooks = hooks or HookManager()
+
+ if retry_policy and not isinstance(retry_policy, RetryPolicy):
+ raise NotAFalyxError("retry_policy must be an instance of RetryPolicy.")
+ retry_policy = retry_policy or RetryPolicy()
+
+ if execution_options:
+ parsed_execution_options = frozenset(
+ ExecutionOption(option) if isinstance(option, str) else option
+ for option in execution_options
+ )
+ else:
+ parsed_execution_options = frozenset()
+
+ command = Command(
+ key=key,
+ description=description,
+ action=action,
+ args=args,
+ kwargs=kwargs if kwargs else {},
+ hidden=hidden,
+ aliases=aliases if aliases else [],
+ help_text=help_text,
+ help_epilog=help_epilog,
+ style=style,
+ confirm=confirm,
+ confirm_message=confirm_message,
+ preview_before_confirm=preview_before_confirm,
+ spinner=spinner,
+ spinner_message=spinner_message,
+ spinner_type=spinner_type,
+ spinner_style=spinner_style,
+ spinner_speed=spinner_speed,
+ tags=tags if tags else [],
+ logging_hooks=logging_hooks,
+ hooks=hooks,
+ retry=retry,
+ retry_all=retry_all,
+ retry_policy=retry_policy,
+ options_manager=options_manager,
+ arg_parser=arg_parser,
+ execution_options=parsed_execution_options,
+ arguments=arguments or [],
+ 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 or {},
+ simple_help_signature=simple_help_signature,
+ ignore_in_history=ignore_in_history,
+ program=program,
+ )
+
+ for hook in before_hooks or []:
+ command.hooks.register(HookType.BEFORE, hook)
+ for hook in success_hooks or []:
+ command.hooks.register(HookType.ON_SUCCESS, hook)
+ for hook in error_hooks or []:
+ command.hooks.register(HookType.ON_ERROR, hook)
+ for hook in after_hooks or []:
+ command.hooks.register(HookType.AFTER, hook)
+ for hook in teardown_hooks or []:
+ command.hooks.register(HookType.ON_TEARDOWN, hook)
+
+ if spinner:
+ command.hooks.register(HookType.BEFORE, spinner_before_hook)
+ command.hooks.register(HookType.ON_TEARDOWN, spinner_teardown_hook)
+
+ return command
+
+ def clone_with_overrides(
+ self,
+ *,
+ key: str | None = None,
+ description: str | None = None,
+ action: BaseAction | Callable[..., Any] | None = None,
+ args: tuple | None = None,
+ kwargs: dict[str, Any] | None = None,
+ hidden: bool | None = None,
+ aliases: list[str] | None = None,
+ help_text: str | None = None,
+ help_epilog: str | None = None,
+ style: Style | str | None = None,
+ confirm: bool | None = None,
+ confirm_message: str | None = None,
+ preview_before_confirm: bool | None = None,
+ spinner: bool | None = None,
+ spinner_message: str | None = None,
+ spinner_type: str | None = None,
+ spinner_style: Style | str | None = None,
+ spinner_speed: float | None = None,
+ hooks: HookManager | None = None,
+ retry: bool | None = None,
+ retry_all: bool | None = None,
+ retry_policy: RetryPolicy | None = None,
+ tags: list[str] | None = None,
+ logging_hooks: bool | None = None,
+ options_manager: OptionsManager | None = None,
+ arg_parser: CommandArgumentParser | None = None,
+ execution_options: list[ExecutionOption | str] | None = None,
+ arguments: list[dict[str, Any]] | None = None,
+ argument_config: Callable[[CommandArgumentParser], None] | 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 | None = None,
+ arg_metadata: dict[str, str | dict[str, Any]] | None = None,
+ simple_help_signature: bool | None = None,
+ ignore_in_history: bool | None = None,
+ program: str | None = None,
+ ) -> Command:
+ """Create a clone of the command with specified overrides."""
+ if not arg_parser and self.arg_parser:
+ arg_parser = self.arg_parser.clone_with_overrides(
+ command_key=key or self.key,
+ command_description=description or self.description,
+ command_style=style or self.style,
+ help_text=help_text or self.help_text,
+ help_epilog=help_epilog or self.help_epilog,
+ aliases=aliases if aliases is not None else self.aliases,
+ program=program or self.program,
+ options_manager=options_manager or self.options_manager,
+ )
+ if not hooks and self.hooks:
+ hooks = self.hooks.copy()
+ if not action and self.action:
+ if isinstance(self.action, BaseAction):
+ cloned_action: (
+ BaseAction | Callable[..., Any] | Callable[..., Awaitable[Any]]
+ ) = self.action.clone()
+ elif callable(self.action):
+ cloned_action = self.action
+ else:
+ raise NotAFalyxError("Action must be a BaseAction or callable to clone.")
+ return Command.build(
+ key=key or self.key,
+ description=description or self.description,
+ action=action or cloned_action,
+ args=args if args is not None else self.args,
+ kwargs=kwargs if kwargs is not None else self.kwargs,
+ hidden=hidden if hidden is not None else self.hidden,
+ aliases=aliases if aliases is not None else self.aliases,
+ help_text=help_text if help_text is not None else self.help_text,
+ help_epilog=help_epilog if help_epilog is not None else self.help_epilog,
+ style=style or self.style,
+ confirm=confirm if confirm is not None else self.confirm,
+ confirm_message=confirm_message or self.confirm_message,
+ preview_before_confirm=(
+ preview_before_confirm
+ if preview_before_confirm is not None
+ else self.preview_before_confirm
+ ),
+ spinner=spinner if spinner is not None else self.spinner,
+ spinner_message=spinner_message or self.spinner_message,
+ spinner_type=spinner_type or self.spinner_type,
+ spinner_style=spinner_style or self.spinner_style,
+ spinner_speed=(
+ spinner_speed if spinner_speed is not None else self.spinner_speed
+ ),
+ hooks=hooks or self.hooks,
+ retry=retry if retry is not None else self.retry,
+ retry_all=retry_all if retry_all is not None else self.retry_all,
+ retry_policy=retry_policy or self.retry_policy,
+ tags=tags if tags is not None else self.tags,
+ logging_hooks=(
+ logging_hooks if logging_hooks is not None else self.logging_hooks
+ ),
+ options_manager=options_manager or self.options_manager,
+ arg_parser=arg_parser or self.arg_parser,
+ execution_options=(
+ execution_options
+ if execution_options is not None
+ else (list(self.execution_options) if self.execution_options else [])
+ ),
+ arguments=arguments if arguments is not None else (self.arguments or []),
+ argument_config=argument_config or self.argument_config,
+ custom_parser=custom_parser or self.custom_parser,
+ custom_help=custom_help or self.custom_help,
+ custom_tldr=custom_tldr or self.custom_tldr,
+ custom_usage=custom_usage or self.custom_usage,
+ auto_args=auto_args if auto_args is not None else self.auto_args,
+ arg_metadata=(
+ arg_metadata if arg_metadata is not None else (self.arg_metadata or {})
+ ),
+ simple_help_signature=(
+ simple_help_signature
+ if simple_help_signature is not None
+ else self.simple_help_signature
+ ),
+ ignore_in_history=(
+ ignore_in_history
+ if ignore_in_history is not None
+ else self.ignore_in_history
+ ),
+ program=program or self.program,
+ )
diff --git a/falyx/command_executor.py b/falyx/command_executor.py
new file mode 100644
index 0000000..2037eb2
--- /dev/null
+++ b/falyx/command_executor.py
@@ -0,0 +1,311 @@
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Shared command execution engine for the Falyx CLI framework.
+
+This module defines `CommandExecutor`, a low-level execution service responsible
+for running already-resolved `Command` objects with a consistent outer lifecycle.
+
+`CommandExecutor` sits between higher-level orchestration layers (such as
+`Falyx.execute_command()` or `CommandRunner.run()`) and the command itself.
+It does not perform command lookup or argument parsing. Instead, it accepts a
+resolved `Command` plus prepared `args`, `kwargs`, and `execution_args`, then
+applies executor-level behavior around the command invocation.
+
+Responsibilities:
+ - Apply execution-scoped runtime overrides such as confirmation flags
+ - Apply retry overrides from execution arguments
+ - Trigger executor-level lifecycle hooks
+ - Create and manage an outer `ExecutionContext`
+ - Delegate actual invocation to the resolved `Command`
+ - Handle interruption and failure policies
+ - Optionally print execution summaries via `ExecutionRegistry`
+
+Execution Model:
+ 1. A command is resolved and its arguments are prepared elsewhere.
+ 2. Retry and execution-option overrides are derived from `execution_args`.
+ 3. An outer `ExecutionContext` is created for executor-level tracking.
+ 4. Executor hooks are triggered around the command invocation.
+ 5. The command is executed inside an `OptionsManager.override_namespace()`
+ context for scoped runtime overrides.
+ 6. Errors are either surfaced, wrapped, or rendered depending on the
+ configured execution policy.
+ 7. Optional summary output is emitted after execution completes.
+
+Design Notes:
+ - `CommandExecutor` is intentionally narrower in scope than `Falyx`.
+ It does not resolve commands, parse raw CLI text, or manage menu state.
+ - `Command` still owns command-local behavior such as confirmation,
+ command hooks, and delegation to the underlying `Action`.
+ - This module exists to centralize shared execution behavior and reduce
+ duplication across Falyx runtime entrypoints.
+
+Typical Usage:
+ executor = CommandExecutor(options=options, hooks=hooks)
+ result = await executor.execute(
+ command=command,
+ args=args,
+ kwargs=kwargs,
+ execution_args=execution_args,
+ )
+"""
+from __future__ import annotations
+
+from typing import Any
+
+from falyx.action import Action
+from falyx.command import Command
+from falyx.context import ExecutionContext
+from falyx.exceptions import FalyxError
+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
+
+
+class CommandExecutor:
+ """Execute resolved Falyx commands with shared outer lifecycle handling.
+
+ `CommandExecutor` provides a reusable execution service for running a
+ `Command` after command resolution and argument parsing have already been
+ completed.
+
+ This class is intended to be shared by higher-level entrypoints such as
+ `Falyx` and `CommandRunner`. It centralizes the outer execution flow so
+ command execution semantics remain consistent across menu-driven and
+ programmatic use cases.
+
+ Responsibilities:
+ - Apply retry overrides from execution arguments
+ - Apply scoped runtime overrides using `OptionsManager`
+ - Trigger executor-level hooks before and after command execution
+ - Create and manage an executor-level `ExecutionContext`
+ - Control whether errors are raised or wrapped
+ - Emit optional execution summaries
+
+ Attributes:
+ options_manager (OptionsManager): Shared options manager used to apply scoped
+ execution overrides.
+ hooks (HookManager): Hook manager for executor-level lifecycle hooks.
+ """
+
+ def __init__(
+ self,
+ *,
+ options_manager: OptionsManager,
+ hooks: HookManager,
+ ) -> None:
+ self.options_manager = options_manager
+ self.hooks = hooks
+
+ def _debug_hooks(self, command: Command) -> None:
+ """Log executor-level and command-level hook registrations for debugging.
+
+ This helper is used to surface the currently registered hooks on both the
+ executor and the resolved command before execution begins.
+
+ Args:
+ command (Command): The command about to be executed.
+ """
+ logger.debug("executor hooks:\n%s", str(self.hooks))
+ logger.debug("['%s'] hooks:\n%s", command.key, str(command.hooks))
+
+ def _apply_retry_overrides(
+ self,
+ command: Command,
+ execution_args: dict[str, Any],
+ ) -> None:
+ """Apply retry-related execution overrides to the command.
+
+ This method inspects execution-level retry options and updates the
+ command's retry policy in place when overrides are provided. If the
+ command's underlying action is an `Action`, the updated retry policy is
+ propagated to that action as well.
+
+ Args:
+ command (Command): The command whose retry policy may be updated.
+ execution_args (dict[str, Any]): Execution-level arguments that may
+ contain retry overrides such as `retries`, `retry_delay`, and
+ `retry_backoff`.
+
+ Notes:
+ - If no retry-related overrides are provided, this method does nothing.
+ - If the command action is not an `Action`, a warning is logged and the
+ command-level retry policy is updated without propagating it further.
+ """
+ retries = execution_args.get("retries", 0)
+ retry_delay = execution_args.get("retry_delay", 0.0)
+ retry_backoff = execution_args.get("retry_backoff", 0.0)
+
+ logger.debug(
+ "[_apply_retry_overrides]: retries=%s, retry_delay=%s, retry_backoff=%s",
+ retries,
+ retry_delay,
+ retry_backoff,
+ )
+ if not retries and not retry_delay and not retry_backoff:
+ return
+
+ command.retry_policy.enabled = True
+ if retries:
+ command.retry_policy.max_retries = retries
+ if retry_delay:
+ command.retry_policy.delay = retry_delay
+ if retry_backoff:
+ command.retry_policy.backoff = retry_backoff
+
+ if isinstance(command.action, Action):
+ command.action.set_retry_policy(command.retry_policy)
+ else:
+ logger.warning(
+ "[%s] Retry requested, but action is not an Action instance.",
+ command.key,
+ )
+
+ def _execution_option_overrides(
+ self,
+ execution_args: dict[str, Any],
+ ) -> dict[str, Any]:
+ """Build scoped option overrides from execution arguments.
+
+ This method extracts execution-only runtime flags that should be applied
+ through the `OptionsManager` during command execution.
+
+ Args:
+ execution_args (dict[str, Any]): Execution-level arguments returned
+ from command argument resolution.
+
+ Returns:
+ dict[str, Any]: Mapping of option names to temporary execution-scoped
+ override values.
+ """
+ return {
+ "force_confirm": execution_args.get("force_confirm", False),
+ "skip_confirm": execution_args.get("skip_confirm", False),
+ }
+
+ async def execute(
+ self,
+ *,
+ command: Command,
+ args: tuple,
+ kwargs: dict[str, Any],
+ execution_args: dict[str, Any],
+ raise_on_error: bool = True,
+ wrap_errors: bool = False,
+ summary_last_result: bool = False,
+ ) -> Any:
+ """Execute a resolved command with executor-level lifecycle management.
+
+ This method is the primary entrypoint of `CommandExecutor`. It accepts an
+ already-resolved `Command` and its prepared execution inputs, then applies
+ shared outer execution behavior around the command invocation.
+
+ Execution Flow:
+ 1. Log currently registered hooks for debugging.
+ 2. Apply retry overrides from `execution_args`.
+ 3. Derive scoped runtime overrides for the execution namespace.
+ 4. Create and start an outer `ExecutionContext`.
+ 5. Trigger executor-level `BEFORE` hooks.
+ 6. Execute the command inside an execution-scoped options override
+ context.
+ 7. Trigger executor-level `SUCCESS` or `ERROR` hooks.
+ 8. Trigger `AFTER` and `ON_TEARDOWN` hooks.
+ 9. Optionally print an execution summary.
+
+ Args:
+ command (Command): The resolved command to execute.
+ args (tuple): Positional arguments to pass to the command.
+ kwargs (dict[str, Any]): Keyword arguments to pass to the command.
+ execution_args (dict[str, Any]): Execution-only arguments that affect
+ runtime behavior, such as retry or confirmation overrides.
+ raise_on_error (bool): Whether execution errors should be re-raised
+ after handling.
+ wrap_errors (bool): Whether handled errors should be wrapped in a
+ `FalyxError` before being raised.
+ summary_last_result (bool): Whether summary output should only have the
+ last recorded result when summary reporting is enabled.
+
+ Returns:
+ Any: The result returned by the command, or any recovered result
+ attached to the execution context.
+
+ Raises:
+ KeyboardInterrupt: If execution is interrupted by the user and
+ `raise_on_error` is True and `wrap_errors` is False.
+ EOFError: If execution receives EOF interruption and `raise_on_error`
+ is True and `wrap_errors` is False.
+ FalyxError: If `wrap_errors` is True and execution is interrupted or
+ fails.
+ Exception: Re-raises the underlying execution error when
+ `raise_on_error` is True and `wrap_errors` is False.
+
+ Notes:
+ - This method assumes the command has already been resolved and its
+ arguments have already been parsed.
+ - Command-local behavior, such as confirmation prompts and command hook
+ execution, remains the responsibility of `Command.__call__()`.
+ - 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)
+
+ context = ExecutionContext(
+ name=command.description,
+ args=args,
+ kwargs=kwargs,
+ action=command,
+ )
+ logger.info(
+ "[execute] Starting execution of '%s' with args: %s, kwargs: %s",
+ command.description,
+ args,
+ kwargs,
+ )
+ context.start_timer()
+
+ try:
+ await self.hooks.trigger(HookType.BEFORE, context)
+ with self.options_manager.override_namespace(
+ overrides=overrides,
+ namespace_name="execution",
+ ):
+ result = await command(*args, **kwargs)
+ context.result = result
+ await self.hooks.trigger(HookType.ON_SUCCESS, context)
+ except (KeyboardInterrupt, EOFError) as error:
+ logger.info(
+ "[execute] '%s' interrupted by user.",
+ command.key,
+ )
+ if wrap_errors:
+ raise FalyxError(
+ f"[execute] '{command.key}' interrupted by user."
+ ) from 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)
+ if wrap_errors:
+ raise FalyxError(f"[execute] '{command.key}' failed: {error}") from error
+ raise error
+ finally:
+ context.stop_timer()
+ await self.hooks.trigger(HookType.AFTER, context)
+ await self.hooks.trigger(HookType.ON_TEARDOWN, context)
+ if execution_args.get("summary") and summary_last_result:
+ er.summary(last_result=True)
+ elif execution_args.get("summary"):
+ er.summary()
+ return context.result
diff --git a/falyx/command_runner.py b/falyx/command_runner.py
new file mode 100644
index 0000000..0948021
--- /dev/null
+++ b/falyx/command_runner.py
@@ -0,0 +1,543 @@
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Standalone command runner for the Falyx CLI framework.
+
+This module defines `CommandRunner`, a developer-facing convenience wrapper for
+executing a single `Command` outside the full `Falyx` runtime.
+
+`CommandRunner` is designed for programmatic and standalone command execution
+where command lookup, menu interaction, and root CLI parsing are not needed.
+It provides a small, focused API that:
+
+- owns a single `Command`
+- ensures the command and parser share a consistent `OptionsManager`
+- delegates shared execution behavior to `CommandExecutor`
+- supports both wrapping an existing `Command` and building one from raw
+ constructor-style arguments
+
+Responsibilities:
+ - Hold a single resolved `Command` for repeated execution
+ - Normalize runtime dependencies such as `OptionsManager`, `HookManager`,
+ and `Console`
+ - Resolve command arguments from raw argv-style input
+ - Delegate execution to `CommandExecutor` for shared outer lifecycle
+ handling
+
+Design Notes:
+ - `CommandRunner` is intentionally narrower than `Falyx`.
+ It does not resolve commands by name, render menus, or manage built-ins.
+ - `CommandExecutor` remains the shared execution core.
+ `CommandRunner` exists as a convenience layer for developer-facing and
+ standalone use cases.
+ - `Command` still owns command-local behavior such as confirmation,
+ command hook execution, and delegation to the underlying `Action`.
+
+Typical Usage:
+ runner = CommandRunner.from_command(existing_command)
+ result = await runner.run(["--region", "us-east"])
+
+ #!/usr/bin/env python
+ import asyncio
+ runner = CommandRunner.build(
+ key="D",
+ description="Deploy",
+ action=deploy,
+ )
+ result = asyncio.run(runner.cli())
+ $ ./deploy.py --region us-east
+"""
+from __future__ import annotations
+
+import asyncio
+import sys
+from typing import Any, Callable
+
+from rich.console import Console
+
+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.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
+from falyx.options_manager import OptionsManager
+from falyx.parser.command_argument_parser import CommandArgumentParser
+from falyx.protocols import ArgParserProtocol
+from falyx.retry import RetryPolicy
+from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
+from falyx.themes import OneColors
+
+
+class CommandRunner:
+ """Run a single Falyx command outside the full Falyx application runtime.
+
+ `CommandRunner` is a lightweight wrapper around a single `Command` plus a
+ `CommandExecutor`. It is intended for standalone execution, testing, and
+ developer-facing programmatic usage where command resolution has already
+ happened or is unnecessary.
+
+ This class is responsible for:
+ - storing the bound `Command`
+ - providing a shared `OptionsManager` to the command and its parser
+ - exposing a simple `run()` method that accepts argv-style input
+ - delegating shared execution behavior to `CommandExecutor`
+
+ Attributes:
+ command (Command): The command executed by this runner.
+ program (str): Program name used in CLI usage text and help output.
+ options_manager (OptionsManager): Shared options manager used by the command,
+ parser, and executor.
+ runner_hooks (HookManager): Executor-level hooks used during execution.
+ console (Console): Rich console used for user-facing output.
+ executor (CommandExecutor): Shared execution engine used to run the
+ bound command.
+ """
+
+ def __init__(
+ self,
+ command: Command,
+ *,
+ program: str | None = None,
+ options_manager: OptionsManager | None = None,
+ runner_hooks: HookManager | None = None,
+ console: Console | None = None,
+ ) -> None:
+ """Initialize a `CommandRunner` for a single command.
+
+ The runner ensures that the bound command, its argument parser, and the
+ internal `CommandExecutor` all share the same `OptionsManager` and runtime
+ dependencies.
+
+ 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_manager (OptionsManager | None): Optional shared options manager. If
+ omitted, a new `OptionsManager` is created.
+ runner_hooks (HookManager | None): Optional executor-level hook manager. If
+ omitted, a new `HookManager` is created.
+ console (Console | None): Optional Rich console for output. If omitted,
+ the default Falyx console is used.
+ """
+ self.command = command
+ self.program = program or ""
+ self.options_manager = self._get_options_manager(options_manager)
+ 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_manager
+ if program:
+ self.command.program = program
+ if isinstance(self.command.arg_parser, CommandArgumentParser):
+ self.command.arg_parser.set_options_manager(self.options_manager)
+ self.command.arg_parser.is_runner_mode = True
+ if program:
+ self.command.arg_parser.program = program
+ self.executor = CommandExecutor(
+ options_manager=self.options_manager,
+ hooks=self.runner_hooks,
+ )
+ if not self.options_manager.get_namespace("root"):
+ self.options_manager.from_mapping(values={}, namespace_name="root")
+ if not self.options_manager.get_namespace("execution"):
+ self.options_manager.from_mapping(values={}, namespace_name="execution")
+
+ def _get_console(self, console) -> Console:
+ if console is None:
+ return falyx_console
+ elif isinstance(console, Console):
+ return console
+ else:
+ raise NotAFalyxError("console must be an instance of rich.Console or None.")
+
+ def _get_options_manager(
+ self,
+ options_manager: OptionsManager | None,
+ ) -> OptionsManager:
+ if options_manager is None:
+ return OptionsManager()
+ elif isinstance(options_manager, OptionsManager):
+ return options_manager
+ else:
+ raise NotAFalyxError(
+ "options_manager must be an instance of OptionsManager or None."
+ )
+
+ def _get_hooks(self, hooks) -> HookManager:
+ if hooks is None:
+ return HookManager()
+ elif isinstance(hooks, HookManager):
+ return hooks
+ else:
+ raise InvalidHookError("hooks must be an instance of HookManager or None.")
+
+ async def run(
+ self,
+ argv: list[str] | str | None = None,
+ raise_on_error: bool = True,
+ wrap_errors: bool = False,
+ summary_last_result: bool = False,
+ ) -> Any:
+ """Resolve arguments and execute the bound command.
+
+ This method is the primary execution entrypoint for `CommandRunner`. It
+ accepts raw argv-style tokens, resolves them into positional arguments,
+ keyword arguments, and execution arguments via `Command.resolve_args()`,
+ then delegates execution to the internal `CommandExecutor`.
+
+ Args:
+ argv (list[str] | str | None): Optional argv-style argument tokens or
+ string (uses `shlex.split()` if a string is provided). If omitted,
+ `sys.argv[1:]` is used.
+
+ Returns:
+ Any: The result returned by the bound command.
+
+ Raises:
+ Exception: Propagates any execution error surfaced by the underlying
+ `CommandExecutor` or command execution path.
+ """
+ argv = sys.argv[1:] if argv is None else argv
+ args, kwargs, execution_args = await self.command.resolve_args(argv)
+ logger.debug(
+ "Executing command '%s' with args=%s, kwargs=%s, execution_args=%s",
+ self.command.description,
+ args,
+ kwargs,
+ execution_args,
+ )
+ return await self.executor.execute(
+ command=self.command,
+ args=args,
+ kwargs=kwargs,
+ execution_args=execution_args,
+ raise_on_error=raise_on_error,
+ wrap_errors=wrap_errors,
+ summary_last_result=summary_last_result,
+ )
+
+ async def cli(
+ self,
+ argv: list[str] | str | None = None,
+ summary_last_result: bool = False,
+ ) -> Any:
+ """Run the bound command as a shell-oriented CLI entrypoint.
+
+ This method wraps `run()` with command-line specific behavior. It executes the
+ bound command using raw argv-style input, then translates framework signals and
+ execution failures into user-facing console output and process exit codes.
+
+ Unlike `run()`, this method is intended for direct CLI usage rather than
+ programmatic integration. It may terminate the current process via `sys.exit()`.
+
+ Behavior:
+ - Delegates normal execution to `run()`
+ - Exits with status code `0` when help output is requested
+ - Exits with status code `2` for command argument or usage errors
+ - Exits with status code `1` for execution failures and non-success control
+ flow such as cancellation or back-navigation
+ - Exits with status code `130` for quit/interrupt-style termination
+
+ Args:
+ argv (list[str] | str | None): Optional argv-style argument tokens or string
+ (uses `shlex.split()` if a string is provided). If omitted, `sys.argv[1:]`
+ is used by `run()`.
+ summary_last_result (bool): Whether summary output should include the last
+ recorded result when summary reporting is enabled.
+
+ Returns:
+ Any: The result returned by the bound command when execution completes
+ successfully.
+
+ Raises:
+ SystemExit: Always raised for handled CLI exit paths, including help,
+ argument errors, cancellations, and execution failures.
+
+ Notes:
+ - This method is intentionally shell-facing and should be used in
+ script entrypoints such as `asyncio.run(runner.cli())`.
+ - For programmatic use, prefer `run()`, which preserves normal Python
+ exception behavior and does not call `sys.exit()`.
+ """
+ try:
+ return await self.run(
+ argv=argv,
+ raise_on_error=False,
+ wrap_errors=True,
+ summary_last_result=summary_last_result,
+ )
+ except HelpSignal:
+ sys.exit(0)
+ except CommandArgumentError as error:
+ self.command.render_help()
+ print_error(message=error)
+ sys.exit(2)
+ except FalyxError as error:
+ print_error(message=error)
+ sys.exit(1)
+ except QuitSignal:
+ logger.info("[QuitSignal]. <- Exiting run.")
+ sys.exit(130)
+ except BackSignal:
+ logger.info("[BackSignal]. <- Exiting run.")
+ sys.exit(1)
+ except CancelSignal:
+ logger.info("[CancelSignal]. <- Exiting run.")
+ sys.exit(1)
+ except asyncio.CancelledError:
+ logger.info("[asyncio.CancelledError]. <- Exiting run.")
+ sys.exit(1)
+
+ @classmethod
+ def from_command(
+ cls,
+ command: Command,
+ *,
+ program: str | None = None,
+ runner_hooks: HookManager | None = None,
+ options_manager: OptionsManager | None = None,
+ console: Console | None = None,
+ ) -> CommandRunner:
+ """Create a `CommandRunner` from an existing `Command` instance.
+
+ This factory is useful when a command has already been defined elsewhere
+ and should be exposed through the standalone runner interface without
+ rebuilding it.
+
+ 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_manager (OptionsManager | None): Optional shared options manager.
+ console (Console | None): Optional Rich console for output.
+
+ Returns:
+ CommandRunner: A runner bound to the provided command.
+
+ Raises:
+ NotAFalyxError: If `runner_hooks` is provided but is not a
+ `HookManager` instance.
+ """
+ if not isinstance(command, Command):
+ raise NotAFalyxError("command must be an instance of Command.")
+ if runner_hooks and not isinstance(runner_hooks, HookManager):
+ raise InvalidHookError("runner_hooks must be an instance of HookManager.")
+ bound_command = command.clone_with_overrides(
+ options_manager=options_manager,
+ program=program,
+ )
+ return cls(
+ command=bound_command,
+ program=program,
+ options_manager=options_manager,
+ runner_hooks=runner_hooks,
+ console=console,
+ )
+
+ @classmethod
+ def build(
+ cls,
+ key: str,
+ description: str,
+ action: BaseAction | Callable[..., Any],
+ *,
+ program: str | None = None,
+ runner_hooks: HookManager | None = None,
+ args: tuple = (),
+ kwargs: dict[str, Any] | None = None,
+ hidden: bool = False,
+ aliases: list[str] | None = None,
+ help_text: str = "",
+ help_epilog: str = "",
+ 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_speed: float = 1.0,
+ options_manager: OptionsManager | None = None,
+ command_hooks: HookManager | None = None,
+ before_hooks: list[Callable] | None = None,
+ success_hooks: list[Callable] | None = None,
+ error_hooks: list[Callable] | None = None,
+ after_hooks: list[Callable] | None = None,
+ teardown_hooks: list[Callable] | None = None,
+ tags: list[str] | None = None,
+ logging_hooks: bool = False,
+ retry: bool = False,
+ retry_all: bool = False,
+ retry_policy: RetryPolicy | None = None,
+ arg_parser: CommandArgumentParser | None = None,
+ arguments: list[dict[str, Any]] | None = None,
+ argument_config: Callable[[CommandArgumentParser], None] | None = None,
+ 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,
+ ignore_in_history: bool = False,
+ console: Console | None = None,
+ ) -> CommandRunner:
+ """Build a `Command` and wrap it in a `CommandRunner`.
+
+ This factory is a convenience constructor for standalone usage. It mirrors
+ the high-level command-building API by creating a configured `Command`
+ through `Command.build()` and then returning a `CommandRunner` bound to it.
+
+ Args:
+ key (str): Primary key used to invoke the command.
+ 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.
+ kwargs (dict[str, Any] | None): Static keyword arguments applied to the
+ command.
+ hidden (bool): Whether the command should be hidden from menu displays.
+ aliases (list[str] | None): Optional alternate invocation names.
+ 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 for 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 integration.
+ spinner_message (str): Spinner message text.
+ spinner_type (str): Spinner animation type.
+ spinner_style (str): Spinner style.
+ spinner_speed (float): Spinner speed multiplier.
+ options_manager (OptionsManager | None): Shared options manager for the command
+ and runner.
+ command_hooks (HookManager | None): Optional hook manager for the built
+ command itself.
+ before_hooks (list[Callable] | None): Command hooks registered for the
+ `BEFORE` lifecycle stage.
+ success_hooks (list[Callable] | None): Command hooks registered for the
+ `ON_SUCCESS` lifecycle stage.
+ error_hooks (list[Callable] | None): Command hooks registered for the
+ `ON_ERROR` lifecycle stage.
+ after_hooks (list[Callable] | None): Command hooks registered for the
+ `AFTER` lifecycle stage.
+ teardown_hooks (list[Callable] | None): Command hooks registered for the
+ `ON_TEARDOWN` lifecycle stage.
+ tags (list[str] | None): Optional tags used for grouping and filtering.
+ logging_hooks (bool): Whether to enable debug hook logging.
+ retry (bool): Whether retry behavior is enabled.
+ retry_all (bool): Whether retry behavior should be applied recursively.
+ retry_policy (RetryPolicy | None): Retry configuration for the command.
+ arg_parser (CommandArgumentParser | None): Optional explicit argument
+ parser instance.
+ arguments (list[dict[str, Any]] | None): Declarative argument
+ definitions.
+ argument_config (Callable[[CommandArgumentParser], None] | None):
+ Callback used to configure the argument parser.
+ execution_options (list[ExecutionOption | str] | None): Execution-level
+ options to enable for the command.
+ custom_parser (ArgParserProtocol | None): Optional custom parser
+ 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
+ metadata used during argument inference.
+ simple_help_signature (bool): Whether to use a simplified help
+ signature.
+ ignore_in_history (bool): Whether to exclude the command from execution
+ history tracking.
+ console (Console | None): Optional Rich console for output.
+
+ Returns:
+ CommandRunner: A runner wrapping the newly built command.
+
+ Raises:
+ 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.
+ - Command construction is delegated to `Command.build()` so command
+ configuration remains centralized.
+ """
+ options_manager = options_manager or OptionsManager()
+ command = Command.build(
+ key=key,
+ description=description,
+ action=action,
+ program=program,
+ args=args,
+ kwargs=kwargs,
+ hidden=hidden,
+ aliases=aliases,
+ help_text=help_text,
+ help_epilog=help_epilog,
+ style=style,
+ confirm=confirm,
+ confirm_message=confirm_message,
+ preview_before_confirm=preview_before_confirm,
+ spinner=spinner,
+ spinner_message=spinner_message,
+ spinner_type=spinner_type,
+ spinner_style=spinner_style,
+ spinner_speed=spinner_speed,
+ tags=tags,
+ logging_hooks=logging_hooks,
+ retry=retry,
+ retry_all=retry_all,
+ retry_policy=retry_policy,
+ options_manager=options_manager,
+ hooks=command_hooks,
+ before_hooks=before_hooks,
+ success_hooks=success_hooks,
+ error_hooks=error_hooks,
+ after_hooks=after_hooks,
+ teardown_hooks=teardown_hooks,
+ arg_parser=arg_parser,
+ execution_options=execution_options,
+ arguments=arguments,
+ 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,
+ ignore_in_history=ignore_in_history,
+ )
+
+ if runner_hooks and not isinstance(runner_hooks, HookManager):
+ raise InvalidHookError("runner_hooks must be an instance of HookManager.")
+
+ return cls(
+ command=command,
+ options_manager=options_manager,
+ runner_hooks=runner_hooks,
+ console=console,
+ )
diff --git a/falyx/completer.py b/falyx/completer.py
index 52f86e3..2d1e7af 100644
--- a/falyx/completer.py
+++ b/falyx/completer.py
@@ -1,22 +1,32 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Provides `FalyxCompleter`, an intelligent autocompletion engine for Falyx CLI
-menus using Prompt Toolkit.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Prompt Toolkit completion support for routed Falyx command input.
-This completer supports:
-- Command key and alias completion (e.g. `R`, `HELP`, `X`)
-- Argument flag completion for registered commands (e.g. `--tag`, `--name`)
-- Context-aware suggestions based on cursor position and argument structure
-- Interactive value completions (e.g. choices and suggestions defined per argument)
-- File/path-friendly behavior, quoting completions with spaces automatically
+This module defines `FalyxCompleter`, the interactive completion layer used by
+Falyx menu and prompt-driven CLI sessions. The completer is routing-aware: it
+delegates namespace traversal to `Falyx.resolve_completion_route()` and only
+hands control to a command's `CommandArgumentParser` after a leaf command has
+been identified.
+Completion behavior is split into two phases:
-Completions are generated from:
-- Registered commands in `Falyx`
-- Argument metadata and `suggest_next()` from `CommandArgumentParser`
+1. Namespace completion
+ While the user is still selecting a command or namespace entry, completion
+ candidates are derived from the active namespace via
+ `completion_names`. Namespace-level help flags such as `-h`, `--help`,
+ `-T`, and `--tldr` are also suggested when appropriate.
+2. Leaf-command completion
+ Once routing reaches a concrete command, the remaining argv fragment is
+ delegated to `CommandArgumentParser.suggest_next()` so command-specific
+ flags, values, choices, and positional suggestions can be surfaced.
-Integrated with the `Falyx.prompt_session` to enhance the interactive experience.
+The completer also supports preview-prefixed input such as `?deploy`, preserves
+shell-safe quoting for suggestions containing whitespace, and integrates
+directly with Prompt Toolkit's completion API by yielding `Completion`
+instances.
+
+Typical usage:
+ session = PromptSession(completer=FalyxCompleter(falyx))
"""
from __future__ import annotations
@@ -33,130 +43,196 @@ if TYPE_CHECKING:
class FalyxCompleter(Completer):
- """
- Prompt Toolkit completer for Falyx CLI command input.
+ """Prompt Toolkit completer for routed Falyx input.
- This completer provides real-time, context-aware suggestions for:
- - Command keys and aliases (resolved via Falyx._name_map)
- - CLI argument flags and values for each command
- - Suggestions and choices defined in the associated CommandArgumentParser
+ `FalyxCompleter` provides context-aware completions for interactive Falyx
+ sessions. It first asks the owning `Falyx` instance to resolve the current
+ input into a partial completion route. Based on that route, it either:
- It leverages `CommandArgumentParser.suggest_next()` to compute valid completions
- based on current argument state, including:
- - Remaining required or optional flags
- - Flag value suggestions (choices or custom completions)
- - Next positional argument hints
- - Inserts longest common prefix (LCP) completions when applicable
- - Handles special cases like quoted strings and spaces
- - Supports dynamic argument suggestions (e.g. flags, file paths, etc.)
+ - suggests visible entries from the active namespace, or
+ - delegates argument completion to the resolved command's argument parser.
+
+ This keeps completion aligned with Falyx's routing model so nested
+ namespaces, preview-prefixed commands, and command-local argument parsing
+ all behave consistently with actual execution.
Args:
- falyx (Falyx): The active Falyx instance providing command and parser context.
+ falyx (Falyx): Active Falyx application instance used to resolve routes
+ and retrieve completion candidates.
"""
- def __init__(self, falyx: "Falyx"):
- self.falyx = falyx
-
- def get_completions(self, document: Document, complete_event) -> Iterable[Completion]:
- """
- Compute completions for the current user input.
-
- Analyzes the input buffer, determines whether the user is typing:
- • A command key/alias
- • A flag/option
- • An argument value
-
- and yields appropriate completions.
+ def __init__(self, falyx: Falyx):
+ """Initialize the completer with a bound Falyx instance.
Args:
- document (Document): The current Prompt Toolkit document (input buffer & cursor).
- complete_event: The triggering event (TAB key, menu display, etc.) — not used here.
+ falyx (Falyx): Active Falyx application that owns the routing and
+ command metadata used for completion.
+ """
+ self.falyx = falyx
+
+ def get_completions(self, document: Document, complete_event):
+ """Yield completions for the current input buffer.
+
+ This method is the main Prompt Toolkit completion entrypoint. It parses
+ the text before the cursor, determines whether the user is still routing
+ through namespaces or has already reached a leaf command, and then
+ yields matching `Completion` objects.
+
+ Behavior:
+ - Splits the current input using `shlex.split()`.
+ - Detects preview-mode input prefixed with `?`.
+ - Separates committed tokens from the active stub under the cursor.
+ - Resolves the partial route through `Falyx.resolve_completion_route()`.
+ - Suggests namespace entries and namespace flags while routing.
+ - Delegates leaf-command completion to
+ `CommandArgumentParser.suggest_next()` once a command is resolved.
+ - Preserves shell-safe quoting for suggestions containing spaces.
+
+ Args:
+ document (Document): Prompt Toolkit document representing the current
+ input buffer and cursor position.
+ complete_event: Prompt Toolkit completion event metadata. It is not
+ currently inspected directly.
Yields:
- Completion: One or more completions matching the current stub text.
+ Completion: Completion candidates appropriate to the current routed
+ input state.
+
+ Notes:
+ - Invalid shell quoting causes completion to stop silently rather
+ than raising.
+ - Command-specific completion is only attempted after a concrete leaf
+ command has been resolved.
"""
text = document.text_before_cursor
try:
tokens = shlex.split(text)
- cursor_at_end_of_token = document.text_before_cursor.endswith((" ", "\t"))
+ cursor_at_end = text.endswith((" ", "\t"))
except ValueError:
return
- if not tokens or (len(tokens) == 1 and not cursor_at_end_of_token):
- # Suggest command keys and aliases
- stub = tokens[0] if tokens else ""
- suggestions = [c.text for c in self._suggest_commands(stub)]
- yield from self._yield_lcp_completions(suggestions, stub)
+ is_preview = False
+ if tokens and tokens[0].startswith("?"):
+ is_preview = True
+ tokens[0] = tokens[0][1:]
+
+ if cursor_at_end:
+ committed_tokens = tokens
+ stub = ""
+ else:
+ committed_tokens = tokens[:-1] if tokens else []
+ stub = tokens[-1] if tokens else ""
+
+ context = self.falyx.get_current_invocation_context().model_copy(
+ update={"is_preview": is_preview}
+ )
+
+ route = self.falyx.resolve_completion_route(
+ committed_tokens,
+ stub=stub,
+ cursor_at_end_of_token=cursor_at_end,
+ invocation_context=context,
+ is_preview=is_preview,
+ )
+
+ # Still selecting an entry in the current namespace
+ if route.expecting_entry:
+ namespace_suggestions, expecting_value = route.namespace.parser.suggest_next(
+ route.remaining_argv, route.cursor_at_end_of_token
+ )
+ yield from self._yield_completions(namespace_suggestions, route.stub)
+ if expecting_value:
+ return
+ suggestions = self._suggest_namespace_entries(route.namespace, route.stub)
+
+ if route.is_preview:
+ suggestions = [f"?{s}" for s in suggestions]
+ current_stub = f"?{route.stub}" if route.stub else "?"
+ else:
+ current_stub = route.stub
+
+ yield from self._yield_lcp_completions(suggestions, current_stub)
return
- # Identify command
- command_key = tokens[0].upper()
- command = self.falyx._name_map.get(command_key)
- if not command or not command.arg_parser:
+ # Leaf command: CAP owns the rest
+ if not route.command or not route.command.arg_parser:
return
- # If at end of token, e.g., "--t" vs "--tag ", add a stub so suggest_next sees it
- parsed_args = tokens[1:] if cursor_at_end_of_token else tokens[1:-1]
- stub = "" if cursor_at_end_of_token else tokens[-1]
+ leaf_tokens = list(route.leaf_argv)
+ if route.stub:
+ leaf_tokens.append(route.stub)
try:
- suggestions = command.arg_parser.suggest_next(
- parsed_args + ([stub] if stub else []), cursor_at_end_of_token
+ suggestions = route.command.arg_parser.suggest_next(
+ leaf_tokens,
+ route.cursor_at_end_of_token,
)
- yield from self._yield_lcp_completions(suggestions, stub)
except Exception:
return
- def _suggest_commands(self, prefix: str) -> Iterable[Completion]:
- """
- Suggest top-level command keys and aliases based on the given prefix.
+ yield from self._yield_completions(suggestions, route.stub)
- Filters all known commands (and `exit`, `help`, `history` built-ins)
- to only those starting with the given prefix.
+ def _suggest_namespace_entries(self, namespace: Falyx, prefix: str) -> list[str]:
+ """Return matching visible entry names for a namespace prefix.
+
+ This helper filters the current namespace's visible completion names so
+ only entries beginning with the provided prefix are returned. Case of the
+ returned value is adjusted to follow the case style of the typed prefix.
Args:
- prefix (str): The current typed prefix.
-
- Yields:
- Completion: Matching keys or aliases from all registered commands.
- """
- keys = [self.falyx.exit_command.key]
- keys.extend(self.falyx.exit_command.aliases)
- if self.falyx.history_command:
- keys.append(self.falyx.history_command.key)
- keys.extend(self.falyx.history_command.aliases)
- if self.falyx.help_command:
- keys.append(self.falyx.help_command.key)
- keys.extend(self.falyx.help_command.aliases)
- for cmd in self.falyx.commands.values():
- keys.append(cmd.key)
- keys.extend(cmd.aliases)
- for key in keys:
- if key.upper().startswith(prefix):
- yield Completion(key.upper(), start_position=-len(prefix))
- elif key.lower().startswith(prefix):
- yield Completion(key.lower(), start_position=-len(prefix))
-
- def _ensure_quote(self, text: str) -> str:
- """
- Ensure that a suggestion is shell-safe by quoting if needed.
-
- Adds quotes around completions containing whitespace so they can
- be inserted into the CLI without breaking tokenization.
-
- Args:
- text (str): The input text to quote.
+ namespace (Falyx): Namespace whose entries should be searched for
+ completion candidates.
+ prefix (str): Current partially typed entry name.
Returns:
- str: The quoted text, suitable for shell command usage.
+ list[str]: Matching namespace entry keys and aliases.
+ """
+ results: list[str] = []
+ for name in namespace.completion_names:
+ # results.append(name)
+ if name.upper().startswith(prefix.upper()):
+ results.append(name.lower() if prefix.islower() else name)
+ return results
+
+ def _ensure_quote(self, text: str) -> str:
+ """Quote a completion candidate when it contains whitespace.
+
+ Args:
+ text (str): Raw completion candidate.
+
+ Returns:
+ str: Shell-safe candidate wrapped in double quotes when needed.
"""
if " " in text or "\t" in text:
return f'"{text}"'
return text
- def _yield_lcp_completions(self, suggestions, stub):
+ def _yield_completions(
+ self,
+ suggestions: list[str],
+ stub: str,
+ ) -> Iterable[Completion]:
+ """Yield Completion objects for a list of suggestion strings.
+
+ This helper converts raw suggestion strings into Prompt Toolkit `Completion`
+ instances with appropriate insertion behavior. It assumes that the caller
+ has already determined the correct start position for insertion.
+
+ Args:
+ suggestions (list[str]): Raw completion candidates to convert.
+ stub (str): The currently typed prefix (used to offset insertion).
"""
- Yield completions for the current stub using longest-common-prefix logic.
+ for suggestion in suggestions:
+ yield Completion(
+ self._ensure_quote(suggestion),
+ start_position=-len(stub),
+ display=suggestion,
+ )
+
+ def _yield_lcp_completions(
+ self, suggestions: list[str], stub: str
+ ) -> Iterable[Completion]:
+ """Yield completions for the current stub using longest-common-prefix logic.
Behavior:
- If only one match → yield it fully.
@@ -171,26 +247,35 @@ class FalyxCompleter(Completer):
Yields:
Completion: Completion objects for the Prompt Toolkit menu.
"""
- matches = [s for s in suggestions if s.startswith(stub)]
+
+ if not suggestions:
+ return
+
+ matches = list(dict.fromkeys(s for s in suggestions if s.startswith(stub)))
if not matches:
return
lcp = os.path.commonprefix(matches)
if len(matches) == 1:
+ match = matches[0]
yield Completion(
- self._ensure_quote(matches[0]),
+ self._ensure_quote(match),
start_position=-len(stub),
- display=matches[0],
+ display=match,
+ )
+ return
+
+ if len(lcp) > len(stub) and not lcp.startswith("-"):
+ yield Completion(
+ self._ensure_quote(lcp),
+ start_position=-len(stub),
+ display=lcp,
+ )
+
+ for match in matches:
+ yield Completion(
+ self._ensure_quote(match),
+ start_position=-len(stub),
+ display=match,
)
- elif len(lcp) > len(stub) and not lcp.startswith("-"):
- yield Completion(lcp, start_position=-len(stub), display=lcp)
- for match in matches:
- yield Completion(
- self._ensure_quote(match), start_position=-len(stub), display=match
- )
- else:
- for match in matches:
- yield Completion(
- self._ensure_quote(match), start_position=-len(stub), display=match
- )
diff --git a/falyx/completer_types.py b/falyx/completer_types.py
new file mode 100644
index 0000000..0bb04b7
--- /dev/null
+++ b/falyx/completer_types.py
@@ -0,0 +1,91 @@
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Completion route models for routed Falyx autocompletion.
+
+This module defines `CompletionRoute`, a lightweight value object used by the
+Falyx completion system to describe the partially resolved state of interactive
+input during autocompletion.
+
+`CompletionRoute` sits at the boundary between namespace routing and
+command-local argument completion. It captures enough information for the
+completer to determine whether it should continue suggesting namespace entries
+or delegate to a resolved command's argument parser.
+
+Typical usage:
+ - A user types part of a namespace path or command key.
+ - Falyx resolves as much of that path as possible.
+ - The resulting `CompletionRoute` describes the active namespace, any
+ resolved leaf command, the remaining argv fragment, and the current
+ token stub under the cursor.
+ - `FalyxCompleter` uses this information to decide what completions to
+ surface next.
+
+This module is intentionally small and focused. It does not perform routing or
+completion itself; it only models the routed state needed by the completer.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING
+
+from falyx.context import InvocationContext
+
+if TYPE_CHECKING:
+ from falyx.command import Command
+ from falyx.falyx import Falyx
+
+
+@dataclass(slots=True)
+class CompletionRoute:
+ """Represents a partially resolved route used during autocompletion.
+
+ A `CompletionRoute` describes the current routed state of user input while
+ Falyx is generating interactive completions. It distinguishes between two
+ broad states:
+
+ - namespace-routing state, where the user is still selecting a visible entry
+ within the current namespace
+ - leaf-command state, where a concrete command has been resolved and the
+ remaining input should be completed by that command's argument parser
+
+ Attributes:
+ namespace (Falyx): The active namespace in which completion is currently
+ taking place.
+ context (InvocationContext): Invocation-path context used to preserve the
+ routed command path and render context-aware help or usage text.
+ command (Command | None): The resolved leaf command, if routing has
+ already reached a concrete command. Remains `None` while the user is
+ still navigating namespaces.
+ leaf_argv (list[str]): Remaining command-local argv tokens that belong to
+ the resolved leaf command. These are typically passed to the
+ command's argument parser for completion.
+ remaining_argv (list[str]): Remaining argv tokens that have not yet been
+ consumed by routing or command resolution. These are typically passed
+ to the next routing or parsing stage for further resolution.
+ stub (str): The current token fragment under the cursor. This is the
+ partial text that completion candidates should replace or extend.
+ cursor_at_end_of_token (bool): Whether the cursor is positioned at the
+ end of a completed token boundary, such as immediately after a
+ trailing space.
+ expecting_entry (bool): Whether completion should suggest namespace
+ entries rather than command-local arguments.
+ is_preview (bool): Whether the input is in preview mode, such as when
+ the user begins the invocation with `?`.
+
+ Notes:
+ - This model is completion-only and is intentionally separate from
+ full execution routing types such as `RouteResult`.
+ - `CompletionRoute` does not validate or parse command arguments; it
+ only records the routed state needed to decide what should complete
+ next.
+ """
+
+ namespace: Falyx
+ context: InvocationContext
+ command: Command | None = None
+ leaf_argv: list[str] = field(default_factory=list)
+ remaining_argv: list[str] = field(default_factory=list)
+ stub: str = ""
+ cursor_at_end_of_token: bool = False
+ expecting_entry: bool = False
+ is_preview: bool = False
diff --git a/falyx/config.py b/falyx/config.py
index 6a8219d..fd36eba 100644
--- a/falyx/config.py
+++ b/falyx/config.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Configuration loader and schema definitions for the Falyx CLI framework.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Configuration loader and schema definitions for the Falyx CLI framework.
This module supports config-driven initialization of CLI commands and submenus
from YAML or TOML files. It enables declarative command definitions, auto-imports
diff --git a/falyx/console.py b/falyx/console.py
index ab3f8c4..0e2cca7 100644
--- a/falyx/console.py
+++ b/falyx/console.py
@@ -1,7 +1,22 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Global console instance for Falyx CLI applications."""
from rich.console import Console
-from falyx.themes import get_nord_theme
+from falyx.exceptions import FalyxError
+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:
+ if hint is None and isinstance(message, FalyxError):
+ hint = message.hint
+
+ error_console.print(f"[{OneColors.DARK_RED}]error:[/] {message}")
+ if hint:
+ error_console.print(f"[{OneColors.LIGHT_YELLOW}]hint:[/] {hint}")
diff --git a/falyx/context.py b/falyx/context.py
index 08704f7..745d57c 100644
--- a/falyx/context.py
+++ b/falyx/context.py
@@ -1,19 +1,22 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Execution context management for Falyx CLI actions.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Context models for Falyx execution and invocation state.
-This module defines `ExecutionContext` and `SharedContext`, which are responsible for
-capturing per-action and cross-action metadata during CLI workflow execution. These
-context objects provide structured introspection, result tracking, error recording,
-and time-based performance metrics.
+This module defines the core context objects used throughout Falyx to track both
+runtime execution metadata and routed invocation-path state.
-- `ExecutionContext`: Captures runtime information for a single action execution,
- including arguments, results, exceptions, timing, and logging.
-- `SharedContext`: Maintains shared state and result propagation across
- `ChainedAction` or `ActionGroup` executions.
+It provides:
+ - `ExecutionContext` for per-action execution details such as arguments,
+ results, exceptions, timing, and summary logging.
+ - `SharedContext` for transient shared state across grouped or chained
+ actions, including propagated results, indexed errors, and arbitrary
+ shared data.
+ - `InvocationContext` for capturing the current routed command path as an
+ immutable value object that supports both plain-text and Rich-markup
+ rendering.
-These contexts enable rich introspection, traceability, and workflow coordination,
-supporting hook lifecycles, retries, and structured output generation.
+Together, these models support Falyx lifecycle hooks, execution tracing,
+history/introspection, and context-aware help and usage rendering across CLI
+and menu modes.
"""
from __future__ import annotations
@@ -24,8 +27,12 @@ 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
class ExecutionContext(BaseModel):
@@ -222,9 +229,9 @@ class SharedContext(BaseModel):
results (list[Any]): Captures results from each action, in order of execution.
errors (list[tuple[int, BaseException]]): Indexed list of errors from failed actions.
current_index (int): Index of the currently executing action (used in chains).
- is_parallel (bool): Whether the context is used in parallel mode (ActionGroup).
+ is_concurrent (bool): Whether the context is used in concurrent mode (ActionGroup).
shared_result (Any | None): Optional shared value available to all actions in
- parallel mode.
+ concurrent mode.
share (dict[str, Any]): Custom shared key-value store for user-defined
communication
between actions (e.g., flags, intermediate data, settings).
@@ -247,7 +254,7 @@ class SharedContext(BaseModel):
results: list[Any] = Field(default_factory=list)
errors: list[tuple[int, BaseException]] = Field(default_factory=list)
current_index: int = -1
- is_parallel: bool = False
+ is_concurrent: bool = False
shared_result: Any | None = None
share: dict[str, Any] = Field(default_factory=dict)
@@ -262,11 +269,11 @@ class SharedContext(BaseModel):
def set_shared_result(self, result: Any) -> None:
self.shared_result = result
- if self.is_parallel:
+ if self.is_concurrent:
self.results.append(result)
def last_result(self) -> Any:
- if self.is_parallel:
+ if self.is_concurrent:
return self.shared_result
return self.results[-1] if self.results else None
@@ -277,14 +284,155 @@ class SharedContext(BaseModel):
self.share[key] = value
def __str__(self) -> str:
- parallel_label = "Parallel" if self.is_parallel else "Sequential"
+ concurrent_label = "Concurrent" if self.is_concurrent else "Sequential"
return (
- f"<{parallel_label}SharedContext '{self.name}' | "
+ f"<{concurrent_label}SharedContext '{self.name}' | "
f"Results: {self.results} | "
f"Errors: {self.errors}>"
)
+class InvocationContext(BaseModel):
+ """Immutable invocation-path context for routed Falyx help and execution.
+
+ `InvocationContext` captures the current displayable command path as the router
+ descends through namespaces and commands. It stores both the raw typed path
+ (`typed_path`) and a styled segment representation (`segments`) so the same
+ context can be rendered as plain text or Rich markup.
+
+ This model is intended to be treated as an immutable value object. Methods such
+ as `with_path_segment()` and `without_last_path_segment()` return new context
+ instances rather than mutating the existing one.
+
+ Attributes:
+ program (str): Root program name used in CLI-mode help and usage output.
+ 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[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.
+ is_preview (bool): Whether the current invocation is a preview flow rather
+ than a normal execution flow.
+ """
+
+ program: str = ""
+ program_style: Style | str = ""
+ typed_path: list[str] = 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.
+
+ Returns:
+ bool: `True` when the invocation is not in menu mode, meaning rendered
+ paths should include the program name. `False` when in menu mode.
+ """
+ return self.mode != FalyxMode.MENU
+
+ def with_path_segment(
+ self,
+ token: str,
+ *,
+ style: Style | str | None = None,
+ ) -> InvocationContext:
+ """Return a new context with one additional path segment appended.
+
+ This method preserves the current context and creates a new
+ `InvocationContext` with the provided token added to both `typed_path` and
+ `segments`.
+
+ Args:
+ token (str): Raw path token to append, such as a namespace key,
+ command key, or alias.
+ style (str | None): Optional Rich style for the appended segment.
+
+ Returns:
+ InvocationContext: A new context containing the appended path segment.
+ """
+ return InvocationContext(
+ program=self.program,
+ program_style=self.program_style,
+ typed_path=[*self.typed_path, token],
+ segments=[*self.segments, StyledSegment(text=token, style=style)],
+ mode=self.mode,
+ is_preview=self.is_preview,
+ )
+
+ def without_last_path_segment(self) -> InvocationContext:
+ """Return a new context with the last path segment removed.
+
+ This method preserves the current context and creates a new
+ `InvocationContext` with the last token removed from both `typed_path` and
+ `segments`.
+
+ Returns:
+ InvocationContext: A new context with the last path segment removed, or the
+ current context if no path segments are present.
+ """
+ if not self.typed_path:
+ return self
+ return InvocationContext(
+ program=self.program,
+ program_style=self.program_style,
+ typed_path=self.typed_path[:-1],
+ segments=self.segments[:-1],
+ mode=self.mode,
+ is_preview=self.is_preview,
+ )
+
+ @property
+ def plain_path(self) -> str:
+ """Render the invocation path as plain text.
+
+ In CLI mode, the rendered path includes the root program name followed by
+ all collected path segments. In menu mode, only the collected path segments
+ are rendered.
+
+ Returns:
+ str: Plain-text invocation path suitable for logs, comparisons, or
+ non-styled help output.
+ """
+ parts = [seg.text for seg in self.segments]
+ if self.is_cli_mode:
+ return " ".join([self.program, *parts]).strip()
+ return " ".join(parts).strip()
+
+ @property
+ def markup_path(self) -> str:
+ """Render the invocation path as escaped Rich markup.
+
+ 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 `StyledSegment.style` value when present.
+
+ Returns:
+ str: Rich-markup invocation path suitable for help and usage rendering.
+ """
+ parts: list[str] = []
+ if self.is_cli_mode and self.program:
+ if self.program_style:
+ parts.append(
+ f"[{self.program_style}]{escape(self.program)}[/{self.program_style}]"
+ )
+ else:
+ parts.append(escape(self.program))
+
+ for seg in self.segments:
+ if seg.style:
+ parts.append(f"[{seg.style}]{escape(seg.text)}[/{seg.style}]")
+ else:
+ parts.append(escape(seg.text))
+ return " ".join(parts).strip()
+
+
if __name__ == "__main__":
import asyncio
diff --git a/falyx/debug.py b/falyx/debug.py
index fbd3096..448176c 100644
--- a/falyx/debug.py
+++ b/falyx/debug.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Provides debug logging hooks for Falyx action execution.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Provides debug logging hooks for Falyx action execution.
This module defines lifecycle hook functions (`log_before`, `log_success`, `log_after`, `log_error`)
that can be registered with a `HookManager` to trace command execution.
diff --git a/falyx/display_types.py b/falyx/display_types.py
new file mode 100644
index 0000000..d8d64b6
--- /dev/null
+++ b/falyx/display_types.py
@@ -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)
diff --git a/falyx/exceptions.py b/falyx/exceptions.py
index b64e6bd..30c2175 100644
--- a/falyx/exceptions.py
+++ b/falyx/exceptions.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines all custom exception classes used in the Falyx CLI framework.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Defines all custom exception classes used in the Falyx CLI framework.
These exceptions provide structured error handling for common failure cases,
including command conflicts, invalid actions or hooks, parser errors, and execution guards
@@ -18,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.
@@ -26,11 +26,20 @@ developer-facing problems that should be caught and reported.
class FalyxError(Exception):
- """Custom exception for the Menu 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):
- """Exception raised when an command with the same key already exists in the menu."""
+ """Exception raised when an command with the same key already exists in the Falyx instance."""
class InvalidHookError(FalyxError):
@@ -42,7 +51,7 @@ class InvalidActionError(FalyxError):
class NotAFalyxError(FalyxError):
- """Exception raised when the provided submenu is not an instance of Menu."""
+ """Exception raised when the provided object is not an instance of a Falyx class."""
class CircuitBreakerOpen(FalyxError):
@@ -54,12 +63,185 @@ class EmptyChainError(FalyxError):
class EmptyGroupError(FalyxError):
- """Exception raised when the chain is empty."""
+ """Exception raised when the group is empty."""
class EmptyPoolError(FalyxError):
- """Exception raised when the chain is empty."""
+ """Exception raised when the pool is empty."""
-class CommandArgumentError(FalyxError):
+class UsageError(FalyxError):
+ """Exception raised when there is an error in the command usage."""
+
+ def __init__(
+ self,
+ message: str | None = None,
+ hint: str | None = None,
+ show_short_usage: bool = True,
+ ):
+ super().__init__(message, hint)
+ self.show_short_usage = show_short_usage
+
+
+class FalyxOptionError(UsageError):
+ """Exception raised when there is an error in the Falyx option parser."""
+
+
+class CommandArgumentError(UsageError):
"""Exception raised when there is an error in the command argument parser."""
+
+
+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 | str | None = None,
+ actual_count: int | None = None,
+ display_name: str | None = None,
+ show_short_usage: bool = True,
+ ):
+ self.dest = dest
+ self.expected_count = expected_count
+ self.actual_count = actual_count
+ self.display_name = display_name or dest
+ super().__init__(
+ self.build_message(),
+ self.build_hint(),
+ show_short_usage=show_short_usage,
+ dest=dest,
+ )
+
+ def build_message(self) -> str:
+ if self.expected_count is None or self.expected_count in (1, "+"):
+ return f"missing value for '{self.display_name}'"
+
+ actual = 0 if self.actual_count is None else self.actual_count
+ return (
+ f"missing values for '{self.display_name}': "
+ f"expected {self.expected_count}, got {actual}"
+ )
+
+ def build_hint(self) -> str | None:
+ if self.expected_count is None or self.expected_count == 1:
+ return f"provide a value for '{self.display_name}'."
+ elif self.expected_count == "+":
+ return f"provide one or more values for '{self.display_name}'."
+ return f"provide {self.expected_count} values for '{self.display_name}'."
+
+
+class TokenizationError(UsageError):
+ raw_input: str | None = None
diff --git a/falyx/execution_option.py b/falyx/execution_option.py
new file mode 100644
index 0000000..42ae23b
--- /dev/null
+++ b/falyx/execution_option.py
@@ -0,0 +1,61 @@
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Execution option enums for the Falyx command runtime.
+
+This module defines `ExecutionOption`, the enum used to represent optional
+execution-scoped behaviors that a command may choose to expose through its
+argument parser.
+
+Execution options are distinct from normal command inputs. They control runtime
+behavior around command execution rather than the business-logic arguments
+passed to the underlying action. Typical examples include summary output,
+retry configuration, and confirmation handling.
+
+`ExecutionOption` is used by Falyx components such as `Command` and
+`CommandArgumentParser` to declaratively enable execution-level flags and to
+normalize user- or config-provided option names into a validated enum value.
+
+The enum also implements custom missing-value handling so string inputs can be
+resolved case-insensitively with helpful error messages.
+"""
+from __future__ import annotations
+
+from enum import Enum
+
+
+class ExecutionOption(Enum):
+ """Enumerates optional execution-scoped behaviors supported by Falyx.
+
+ `ExecutionOption` identifies runtime features that can be enabled on a
+ command independently of its normal argument schema. When present, these
+ options typically cause `CommandArgumentParser` to expose additional flags
+ that affect how the command is executed rather than what the command does.
+
+ Supported options:
+ SUMMARY: Enable summary-related execution flags and reporting behavior.
+ RETRY: Enable retry-related execution flags such as retry count, delay,
+ and backoff.
+ CONFIRM: Enable confirmation-related execution flags such as forcing or
+ skipping confirmation prompts.
+
+ Notes:
+ - These values are intended for execution control, not domain-specific
+ command input.
+ - String values are normalized case-insensitively through `_missing_()`
+ so config and user input can be converted into enum members with
+ friendlier validation behavior.
+ """
+
+ SUMMARY = "summary"
+ RETRY = "retry"
+ CONFIRM = "confirm"
+
+ @classmethod
+ def _missing_(cls, value: object) -> ExecutionOption:
+ 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}")
diff --git a/falyx/execution_registry.py b/falyx/execution_registry.py
index 4f118f1..01d1492 100644
--- a/falyx/execution_registry.py
+++ b/falyx/execution_registry.py
@@ -1,7 +1,6 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Provides the `ExecutionRegistry`, a centralized runtime store for capturing and inspecting
-the execution history of Falyx actions.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Provides the `ExecutionRegistry`, a centralized runtime store for capturing and
+inspecting the execution history of Falyx actions.
The registry automatically records every `ExecutionContext` created during action
execution—including context metadata, results, exceptions, duration, and tracebacks.
@@ -63,8 +62,7 @@ from falyx.themes import OneColors
class ExecutionRegistry:
- """
- Global registry for recording and inspecting Falyx action executions.
+ """Global registry for recording and inspecting Falyx action executions.
This class captures every `ExecutionContext` created by Falyx Actions,
tracking metadata, results, exceptions, and performance metrics. It enables
@@ -96,8 +94,7 @@ class ExecutionRegistry:
@classmethod
def record(cls, context: ExecutionContext):
- """
- Record an execution context and assign a unique index.
+ """Record an execution context and assign a unique index.
This method logs the context, appends it to the registry,
and makes it available for future summary or filtering.
@@ -115,8 +112,7 @@ class ExecutionRegistry:
@classmethod
def get_all(cls) -> list[ExecutionContext]:
- """
- Return all recorded execution contexts in order of execution.
+ """Return all recorded execution contexts in order of execution.
Returns:
list[ExecutionContext]: All stored action contexts.
@@ -125,8 +121,7 @@ class ExecutionRegistry:
@classmethod
def get_by_name(cls, name: str) -> list[ExecutionContext]:
- """
- Retrieve all executions recorded under a given action name.
+ """Return all executions recorded under a given action name.
Args:
name (str): The name of the action.
@@ -138,8 +133,7 @@ class ExecutionRegistry:
@classmethod
def get_latest(cls) -> ExecutionContext:
- """
- Return the most recent execution context.
+ """Return the most recent execution context.
Returns:
ExecutionContext: The last recorded context.
@@ -148,8 +142,7 @@ class ExecutionRegistry:
@classmethod
def clear(cls):
- """
- Clear all stored execution data and reset internal indices.
+ """Clear all stored execution data and reset internal indices.
This operation is destructive and cannot be undone.
"""
@@ -167,8 +160,7 @@ class ExecutionRegistry:
last_result: bool = False,
status: Literal["all", "success", "error"] = "all",
):
- """
- Display a formatted Rich table of recorded executions.
+ """Display a formatted Rich table of recorded executions.
Supports filtering by action name, index, or execution status.
Can optionally show only the last result or a specific indexed result.
@@ -264,7 +256,7 @@ class ExecutionRegistry:
if ctx.exception and status.lower() in ["all", "error"]:
final_status = f"[{OneColors.DARK_RED}]❌ Error"
final_result = repr(ctx.exception)
- elif status.lower() in ["all", "success"]:
+ elif not ctx.exception and status.lower() in ["all", "success"]:
final_status = f"[{OneColors.GREEN}]✅ Success"
final_result = repr(ctx.result)
if len(final_result) > 50:
diff --git a/falyx/falyx.py b/falyx/falyx.py
index 176b969..98267f0 100644
--- a/falyx/falyx.py
+++ b/falyx/falyx.py
@@ -1,22 +1,62 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Main class for constructing and running Falyx CLI menus.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Core application runtime for the Falyx CLI framework.
-Falyx provides a structured, customizable interactive menu system
-for running commands, actions, and workflows. It supports:
+This module defines `Falyx`, the top-level orchestration layer used to build,
+route, render, and execute Falyx applications. `Falyx` sits above individual
+`Command` objects and their local argument parsers.
-- Hook lifecycle management (before/on_success/on_error/after/on_teardown)
-- Dynamic command addition and alias resolution
-- Rich-based menu display with multi-column layouts
-- Interactive input validation and auto-completion
-- History tracking and help menu generation
-- Confirmation prompts and spinners
-- Run key for automated script execution
-- CLI argument parsing with argparse integration
-- Retry policy configuration for actions
+Core Responsibilities:
+- Registration of commands, builtins, and nested namespaces
+- Root/session option parsing
+- Recursive namespace-aware routing
+- Interactive menu prompting and validation
+- Routed autocompletion
+- Namespace and command help/TLDR rendering
+- Execution dispatch through `CommandExecutor`
+- Shared option state and execution history
-Falyx enables building flexible, robust, and user-friendly
-terminal applications with minimal boilerplate.
+Architecture:
+ Falyx is the routing boundary of the framework.
+
+ - `FalyxParser` parses only root-level/session flags and leaves the remaining
+ tokens untouched for routing.
+ - `Falyx.resolve_route()` walks the invocation path recursively across
+ nested `FalyxNamespace` entries until it reaches either a namespace help
+ target, a namespace menu target, an unknown entry, or a leaf `Command`.
+ - Once a leaf command is found, command-local parsing is delegated to that
+ command's `CommandArgumentParser` via `Command.resolve_args()`.
+ - Prepared inputs are then executed through `CommandExecutor`, which applies
+ shared outer execution behavior consistently across CLI and menu flows.
+
+Execution Model:
+ 1. Root CLI/session flags are parsed.
+ 2. The remaining tokens are routed across namespaces and commands.
+ 3. If a leaf command is reached, its remaining argv is parsed locally.
+ 4. The resolved route is rendered, previewed, or executed.
+ 5. Shared hooks, option overrides, and execution tracking are applied.
+
+Interactive Features:
+ In menu mode, `Falyx` integrates Rich and Prompt Toolkit to provide a
+ structured interactive runtime with:
+
+ - persistent prompt history
+ - routed validation
+ - namespace-aware autocompletion
+ - bottom-bar rendering and key bindings
+ - preview flows and contextual help
+ - history and built-in utility commands
+
+Design Notes:
+ - `Falyx` owns routing; commands own leaf argument parsing; the executor owns
+ outer execution behavior.
+ - CLI mode and menu mode share the same routed execution semantics.
+ - Help, usage, and TLDR rendering are invocation-context aware so nested
+ namespaces display correctly scoped command paths.
+ - Builtins such as help, preview, version, history, and exit are registered
+ as first-class entries within the application runtime.
+
+This module is the primary entrypoint for assembling and running a Falyx
+application.
"""
from __future__ import annotations
@@ -24,12 +64,12 @@ import asyncio
import logging
import shlex
import sys
-from argparse import ArgumentParser, Namespace, _SubParsersAction
+from collections.abc import Callable
from difflib import get_close_matches
from functools import cached_property
from pathlib import Path
from random import choice
-from typing import Any, Callable
+from typing import Any
from prompt_toolkit import PromptSession
from prompt_toolkit.application import get_app
@@ -41,256 +81,633 @@ from prompt_toolkit.shortcuts import CompleteStyle
from prompt_toolkit.validation import ValidationError
from rich import box
from rich.console import Console
-from rich.markdown import Markdown
+from rich.markup import escape
from rich.padding import Padding
from rich.panel import Panel
+from rich.style import StyleType
from rich.table import Table
+from rich.text import Text
from falyx.action.action import Action
from falyx.action.base_action import BaseAction
+from falyx.action.signal_action import SignalAction
from falyx.bottom_bar import BottomBar
from falyx.command import Command
+from falyx.command_executor import CommandExecutor
from falyx.completer import FalyxCompleter
-from falyx.console import console
-from falyx.context import ExecutionContext
+from falyx.completer_types import CompletionRoute
+from falyx.console import console, error_console, print_error
+from falyx.context import InvocationContext
from falyx.debug import log_after, log_before, log_error, log_success
from falyx.exceptions import (
CommandAlreadyExistsError,
CommandArgumentError,
+ EntryNotFoundError,
FalyxError,
+ FalyxOptionError,
InvalidActionError,
+ InvalidHookError,
NotAFalyxError,
+ UsageError,
)
+from falyx.execution_option import ExecutionOption
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import Hook, HookManager, HookType
-from falyx.hooks import spinner_before_hook, spinner_teardown_hook
from falyx.logger import logger
from falyx.mode import FalyxMode
+from falyx.namespace import FalyxNamespace
from falyx.options_manager import OptionsManager
-from falyx.parser import CommandArgumentParser, FalyxParsers, get_arg_parsers
+from falyx.parser import CommandArgumentParser, FalyxParser, OptionAction
+from falyx.parser.option import Option
+from falyx.parser.parser_types import FalyxTLDRInput
from falyx.prompt_utils import rich_text_to_prompt_text
from falyx.protocols import ArgParserProtocol
from falyx.retry import RetryPolicy
-from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
+from falyx.routing import RouteKind, RouteResult
+from falyx.signals import BackSignal, CancelSignal, FlowSignal, HelpSignal, QuitSignal
from falyx.themes import OneColors
-from falyx.utils import CaseInsensitiveDict, _noop, chunks, ensure_async
+from falyx.utils import CaseInsensitiveDict, chunks
from falyx.validators import CommandValidator
from falyx.version import __version__
class Falyx:
- """
- Main menu controller for Falyx CLI applications.
+ """Primary controller for Falyx CLI applications.
- Falyx orchestrates the full lifecycle of an interactive menu system,
- handling user input, command execution, error recovery, and structured
- CLI workflows.
+ `Falyx` manages the full runtime of a Falyx application, including command
+ registration, nested namespace traversal, interactive menu behavior, routed
+ help output, and execution dispatch.
- Key Features:
- - Interactive menu with Rich rendering and Prompt Toolkit input handling
- - Dynamic command management with alias and abbreviation matching
- - Full lifecycle hooks (before, success, error, after, teardown) at both menu and
- command levels
- - Built-in retry support, spinner visuals, and confirmation prompts
- - Submenu nesting and action chaining
- - History tracking, help generation, and run key execution modes
- - Seamless CLI argument parsing and integration via argparse
- - Declarative option management with OptionsManager
- - Command level argument parsing and validation
- - Extensible with user-defined hooks, bottom bars, and custom layouts
+ It acts as the central integration point between:
+ - Command definitions (`Command`)
+ - Nested namespaces (`FalyxNamespace`)
+ - Root parser (`FalyxParser`)
+ - Leaf argument parsers (`CommandArgumentParser`)
+ - Execution dispatch (`CommandExecutor`)
+ - Execution units (`Action`, `ChainedAction`, `ActionGroup`)
+ - Shared runtime configuration (`OptionsManager`)
+ - Lifecycle hooks (`HookManager`)
+ - UI layers (Rich + Prompt Toolkit)
- Args:
- title (str | Markdown): Title displayed for the menu.
- prompt (AnyFormattedText): Prompt displayed when requesting user input.
- columns (int): Number of columns to use when rendering menu commands.
- bottom_bar (BottomBar | str | Callable | None): Bottom toolbar content or logic.
- welcome_message (str | Markdown | dict): Welcome message shown at startup.
- exit_message (str | Markdown | dict): Exit message shown on shutdown.
- key_bindings (KeyBindings | None): Custom Prompt Toolkit key bindings.
- include_history_command (bool): Whether to add a built-in history viewer command.
- include_help_command (bool): Whether to add a built-in help viewer command.
- never_prompt (bool): Seed default for `OptionsManager["never_prompt"]`
- force_confirm (bool): Seed default for `OptionsManager["force_confirm"]`
- cli_args (Namespace | None): Parsed CLI arguments, usually from argparse.
- options (OptionsManager | None): Declarative option mappings for global state.
- custom_table (Callable[[Falyx], Table] | Table | None): Custom menu table
- generator.
+ Key Responsibilities:
+ - Maintain a registry of commands, aliases, builtins, and namespaces
+ - Parse root-level/session flags and delegate the rest to routing
+ - Resolve user input to a routed `RouteResult`
+ - Provide namespace-aware completion and validation
+ - Execute commands with full lifecycle hook support
+ - Provide prepared command executions through the shared executor
+ - Render help, usage, and TLDR output with invocation context
+ - Apply execution-scoped option overrides (e.g. confirm, retries)
+ - Manage prompt session state, history, and bottom-bar integration
+ - Record and surface execution
- Methods:
- run(): Main entry point for CLI argument-based workflows. Suggested for
- most use cases.
- menu(): Run the interactive menu loop.
- run_key(command_key, return_context): Run a command directly without the menu.
- add_command(): Add a single command to the menu.
- add_commands(): Add multiple commands at once.
- register_all_hooks(): Register hooks across all commands and submenus.
- debug_hooks(): Log hook registration for debugging.
- build_default_table(): Construct the standard Rich table layout.
+ Routing Model:
+ `Falyx` performs recursive routing across visible entries in the current
+ namespace.
+
+ - If no entry is selected, the route may target the current namespace
+ itself.
+ - If a help or TLDR flag is encountered before a leaf command, the route
+ targets namespace help for the current scope.
+ - If a namespace entry is selected, routing continues inside that nested
+ `Falyx` instance.
+ - If a leaf command is selected, the remaining argv is preserved and
+ delegated unchanged to that command's parser.
+
+ This keeps namespace traversal separate from command-local parsing and
+ ensures completion, validation, help rendering, and execution all share
+ the same routing semantics.
+
+ Execution Semantics:
+ `Falyx` does not parse command-local arguments itself once a leaf command
+ is resolved. Instead, it prepares the route, delegates leaf parsing to
+ the selected command, and forwards the prepared `(args, kwargs,
+ execution_args)` to `CommandExecutor`.
+
+ This separation preserves a clean boundary:
+
+ - `Falyx` routes
+ - `Command` parses
+ - `CommandExecutor` executes
+
+ Interactive Semantics:
+ In menu mode, `Falyx` provides a prompt-driven interface with routed
+ validation and completion. In CLI mode, it applies the same routing and
+ execution pipeline to raw argv-style input. Both modes therefore share
+ the same command behavior, help model, and execution lifecycle.
+
+ Design Notes:
+ - Commands are first-class and may encapsulate complex workflows
+ - Execution options are parsed separately from command arguments
+ - All execution passes through a unified hook lifecycle
+
+ Attributes:
+ title (str | Markdown): Display title for the interactive menu.
+ program (str): Program name used in CLI-facing help and invocation paths.
+ commands (dict[str, Command]): Registered user commands.
+ builtins (dict[str, Command]): Registered built-in commands such as help,
+ preview, and version.
+ namespaces (dict[str, FalyxNamespace]): Registered nested namespaces.
+ options_manager (OptionsManager): Shared runtime option manager.
+ hooks (HookManager): Application-level hook manager.
+ console (Console): Rich console used for rendering output.
+ key_bindings (KeyBindings): Prompt Toolkit key bindings for menu mode.
+ bottom_bar (BottomBar | str | Callable | None): Bottom toolbar renderer.
+ history (FileHistory | None): Optional persistent prompt history backend.
+
+ Raises:
+ FalyxError: If invalid configuration or command registration occurs.
+ CommandAlreadyExistsError: If a command, alias, or namespace identifier
+ collides with an existing entry.
+
+ Notes:
+ - Entry names are resolved case-insensitively.
+ - Builtins and namespaces participate in the same routing surface as
+ normal commands.
+ - Help, TLDR, and usage rendering are scoped by `InvocationContext`,
+ which allows nested namespaces to render accurate command paths.
"""
def __init__(
self,
- title: str | Markdown = "Menu",
+ title: str = "Menu",
*,
program: str | None = "falyx",
usage: str | None = None,
description: str | None = "Falyx CLI - Run structured async command workflows.",
epilog: str | None = None,
+ caption: str | None = None,
version: str = __version__,
- version_style: str = OneColors.BLUE_b,
+ title_style: StyleType = "white bold",
+ program_style: StyleType = OneColors.BLUE_b,
+ usage_style: StyleType = "white",
+ description_style: StyleType = OneColors.BLUE,
+ epilog_style: StyleType = "white",
+ caption_style: StyleType = "white",
+ version_style: StyleType = OneColors.BLUE_b,
prompt: str | StyleAndTextTuples = "> ",
columns: int = 3,
bottom_bar: BottomBar | str | Callable[[], Any] | None = None,
- welcome_message: str | Markdown | dict[str, Any] = "",
- exit_message: str | Markdown | dict[str, Any] = "",
+ welcome_message: str = "",
+ exit_message: str = "",
key_bindings: KeyBindings | None = None,
include_history_command: bool = True,
- include_help_command: bool = True,
never_prompt: bool = False,
force_confirm: bool = False,
- cli_args: Namespace | None = None,
- options: OptionsManager | None = None,
+ verbose: bool = False,
+ debug_hooks: bool = False,
+ options_manager: OptionsManager | None = None,
render_menu: Callable[[Falyx], None] | None = None,
custom_table: Callable[[Falyx], Table] | Table | None = None,
hide_menu_table: bool = False,
show_placeholder_menu: bool = False,
prompt_history_base_dir: Path = Path.home(),
enable_prompt_history: bool = False,
+ enable_help_tips: bool = True,
+ default_to_menu: bool = True,
+ simple_usage: bool = False,
+ disable_verbose_option: bool = False,
+ disable_debug_hooks_option: bool = False,
+ disable_never_prompt_option: bool = False,
) -> None:
- """Initializes the Falyx object."""
- self.title: str | Markdown = title
+ """Initialize a Falyx application runtime.
+
+ This constructor configures the top-level application object used to run a
+ Falyx CLI or interactive menu. It establishes the shared runtime state for
+ command registration, namespace routing, menu rendering, prompt behavior,
+ built-in command availability, and executor-backed dispatch.
+
+ During initialization, `Falyx`:
+
+ - stores application display metadata such as title, description, and version
+ - creates or validates the shared `OptionsManager`
+ - prepares key bindings, prompt rendering, and optional bottom-bar behavior
+ - initializes registries for commands, builtins, and namespaces
+ - registers default built-in commands such as help, preview, and version
+ - optionally enables persistent prompt history
+ - creates the shared `CommandExecutor` used for command dispatch
+
+ The resulting instance is ready to have commands and namespaces added before
+ being executed in CLI or menu mode.
+
+ Args:
+ title (str): Title displayed for the interactive menu or top-level
+ application view.
+ 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.
+ usage (str | None): Optional usage override for namespace-level CLI help. When
+ omitted, usage text is derived from the current invocation context.
+ description (str | None): Short program description shown in top-level help
+ output.
+ epilog (str | None): Optional trailing help text rendered after the main help
+ sections.
+ version (str): Application version string used by the built-in version command.
+ program_style (StyleType): Rich style used when rendering the program name.
+ usage_style (StyleType): Rich style used for rendered usage text.
+ description_style (StyleType): Rich style used for the program description.
+ epilog_style (StyleType): Rich style used for the help epilog.
+ version_style (StyleType): Rich style used for version output and version-related
+ rendering.
+ prompt (str | StyleAndTextTuples): Prompt text or Prompt Toolkit formatted text
+ shown in menu mode.
+ columns (int): Default column count used by menu-oriented UI components such as
+ the bottom bar.
+ bottom_bar (BottomBar | str | Callable[[], Any] | None): Bottom toolbar
+ configuration for menu mode. May be a `BottomBar` instance, a static
+ string, a callable renderer, or `None` to use the default bottom bar.
+ welcome_message (str): Optional welcome content
+ rendered when entering the interactive menu.
+ exit_message (str): Optional exit content rendered
+ when leaving the interactive menu.
+ key_bindings (KeyBindings | None): Optional Prompt Toolkit key bindings for
+ menu interaction. If omitted, a default `KeyBindings` object is created.
+ include_history_command (bool): Whether to register the built-in history
+ command.
+ never_prompt (bool): Default session-level value for the `never_prompt`
+ runtime option.
+ force_confirm (bool): Default session-level value for the `force_confirm`
+ runtime option.
+ verbose (bool): Default session-level value for the `verbose` runtime option.
+ debug_hooks (bool): Default session-level value for the `debug_hooks` runtime option.
+ options_manager (OptionsManager | None): Shared options manager for the application.
+ If omitted, a new `OptionsManager` instance is created.
+ render_menu (Callable[[Falyx], None] | None): Optional custom menu renderer
+ used instead of the default table-based menu output.
+ custom_table (Callable[[Falyx], Table] | Table | None): Optional custom Rich
+ table or table factory used when rendering the default menu view.
+ hide_menu_table (bool): Whether the default menu table should be hidden.
+ show_placeholder_menu (bool): Whether prompt placeholder content should be
+ shown in the interactive prompt.
+ prompt_history_base_dir (Path): Base directory used to store persistent prompt
+ history files when history is enabled.
+ enable_prompt_history (bool): Whether to persist Prompt Toolkit input history
+ to disk.
+ enable_help_tips (bool): Whether to show contextual usage tips in rendered
+ help output.
+ default_to_menu (bool): Whether to enter menu mode if no CLI arguments are
+ provided on startup. If `False`, the application will print help and
+ exit when no arguments are provided.
+ simple_usage (bool): Whether to use a simplified usage format in help output.
+ disable_verbose_option (bool): Whether to omit the built-in `--verbose` option
+ from the root parser.
+ disable_debug_hooks_option (bool): Whether to omit the built-in `--debug-hooks`
+ option from the root parser.
+ disable_never_prompt_option (bool): Whether to omit the built-in `--never-prompt`
+ option from the root parser.
+
+ Raises:
+ FalyxError: If the provided options_manager object is invalid or other core runtime
+ configuration is inconsistent.
+
+ Notes:
+ - Initialization does not execute commands or parse user input.
+ - Default built-ins are registered immediately so they participate in routing,
+ completion, and help rendering from the start.
+ - The prompt session itself is created lazily, allowing UI-related state such
+ as bottom bars and key bindings to be finalized before first use.
+ """
+ self.title: str = title
self.program: str = program or ""
self.usage: str | None = usage
self.description: str | None = description
self.epilog: str | None = epilog
+ self.caption: str | None = caption
self.version: str = version
- self.version_style: str = version_style
+ self.title_style: StyleType = title_style
+ self.program_style: StyleType = program_style
+ self.usage_style: StyleType = usage_style
+ self.description_style: StyleType = description_style
+ self.epilog_style: StyleType = epilog_style
+ self.caption_style: StyleType = caption_style
+ self.version_style: StyleType = version_style
self.prompt: str | StyleAndTextTuples = rich_text_to_prompt_text(prompt)
self.columns: int = columns
self.commands: dict[str, Command] = CaseInsensitiveDict()
+ self.builtins: dict[str, Command] = CaseInsensitiveDict()
+ self.namespaces: dict[str, FalyxNamespace] = CaseInsensitiveDict()
self.console: Console = console
- self.welcome_message: str | Markdown | dict[str, Any] = welcome_message
- self.exit_message: str | Markdown | dict[str, Any] = exit_message
+ self.error_console: Console = error_console
+ self.welcome_message: str = welcome_message
+ self.exit_message: str = exit_message
self.hooks: HookManager = HookManager()
- self.last_run_command: Command | None = None
self.key_bindings: KeyBindings = key_bindings or KeyBindings()
self.bottom_bar: BottomBar | str | Callable[[], None] | None = bottom_bar
self._never_prompt: bool = never_prompt
self._force_confirm: bool = force_confirm
- self.cli_args: Namespace | None = cli_args
+ self._verbose: bool = verbose
+ self._debug_hooks: bool = debug_hooks
self.render_menu: Callable[[Falyx], None] | None = render_menu
self.custom_table: Callable[[Falyx], Table] | Table | None = custom_table
self._hide_menu_table: bool = hide_menu_table
self.show_placeholder_menu: bool = show_placeholder_menu
- self.validate_options(cli_args, options)
+ self._validate_options_manager(options_manager)
self._prompt_session: PromptSession | None = None
- self.options.set("mode", FalyxMode.MENU)
+ self.options_manager.set("mode", FalyxMode.COMMAND)
self.exit_command: Command = self._get_exit_command()
self.history_command: Command | None = (
self._get_history_command() if include_history_command else None
)
- self.help_command: Command | None = (
- self._get_help_command() if include_help_command else None
- )
+ self.help_command: Command = self._get_help_command()
if enable_prompt_history:
- program = (self.program or "falyx").split(".")[0].replace(" ", "_")
+ program = (program or "falyx").split(".")[0].replace(" ", "_")
self.history_path: Path = (
Path(prompt_history_base_dir) / f".{program}_history"
)
self.history: FileHistory | None = FileHistory(self.history_path)
else:
self.history = None
+ self.enable_help_tips: bool = enable_help_tips
+ self.default_to_menu: bool = default_to_menu
+ self.simple_usage: bool = simple_usage
+ self._register_default_builtins()
+ self._register_runtime_options()
+ self._executor = CommandExecutor(
+ options_manager=self.options_manager,
+ hooks=self.hooks,
+ )
+ self.disable_verbose_option: bool = disable_verbose_option
+ self.disable_debug_hooks_option: bool = disable_debug_hooks_option
+ self.disable_never_prompt_option: bool = disable_never_prompt_option
+ self.parser: FalyxParser = FalyxParser(self)
- @property
- def is_cli_mode(self) -> bool:
- """Checks if the current mode is a CLI mode."""
- return self.options.get("mode") in {
- FalyxMode.RUN,
- FalyxMode.PREVIEW,
- FalyxMode.RUN_ALL,
- FalyxMode.HELP,
- }
+ def __str__(self) -> str:
+ return (
+ f"Falyx(program='{self.program}', "
+ f"title='{self.title}', "
+ f"description='{self.description}')"
+ )
- def validate_options(
+ def __repr__(self) -> str:
+ return self.__str__()
+
+ def add_option(
self,
- cli_args: Namespace | None,
- options: OptionsManager | None = None,
+ *flags: str,
+ action: str | OptionAction = "store",
+ default: Any = None,
+ type: Callable[[Any], Any] = str,
+ choices: list[str] | None = None,
+ help: str = "",
+ dest: str | None = None,
+ suggestions: list[str] | None = None,
+ ) -> Option:
+ return self.parser.add_option(
+ *flags,
+ action=action,
+ default=default,
+ type=type,
+ choices=choices,
+ help=help,
+ dest=dest,
+ suggestions=suggestions,
+ )
+
+ def add_tldr_example(
+ self,
+ *,
+ entry_key: str,
+ usage: str,
+ description: str,
) -> None:
- """Checks if the options are set correctly."""
- self.options: OptionsManager = options or OptionsManager()
- if not cli_args and not options:
- return None
+ """Register a single namespace-level TLDR example.
- if options and not cli_args:
- raise FalyxError("Options are set, but CLI arguments are not.")
+ 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.
- assert isinstance(
- cli_args, Namespace
- ), "CLI arguments must be a Namespace object."
+ 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.
- if not isinstance(self.options, OptionsManager):
- raise FalyxError("Options must be an instance of OptionsManager.")
+ Raises:
+ EntryNotFoundError: If `entry_key` cannot be resolved to a known command or
+ namespace in this `Falyx` instance.
+ """
+ self.parser.add_tldr_example(
+ entry_key=entry_key,
+ usage=usage,
+ description=description,
+ )
- if not isinstance(self.cli_args, Namespace):
- raise FalyxError("CLI arguments must be a Namespace object.")
+ 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.
+ """
+ self.parser.add_tldr_examples(examples)
+
+ def get_current_invocation_context(self) -> InvocationContext:
+ """Build the default invocation context for this namespace.
+
+ The returned context starts at the current namespace root and reflects the
+ runtime mode stored in the shared options manager.
+
+ Returns:
+ InvocationContext: Fresh invocation context for help, routing, or
+ completion.
+ """
+ return InvocationContext(
+ program=self.program,
+ program_style=self.program_style,
+ typed_path=[],
+ mode=self.options_manager.get("mode"),
+ )
@property
- def _name_map(self) -> dict[str, Command]:
- """
- Builds a mapping of all valid input names (keys, aliases, normalized names) to
- Command objects. If a collision occurs, logs a warning and keeps the first
- registered command.
- """
- mapping: dict[str, Command] = {}
+ def _is_cli_mode(self) -> bool:
+ """Return whether the application is currently running outside menu mode.
- def register(name: str, cmd: Command):
+ Returns:
+ bool: `True` when the active mode is not `FalyxMode.MENU`.
+ """
+ return self.options_manager.get("mode") != FalyxMode.MENU
+
+ def _validate_options_manager(
+ self,
+ options_manager: OptionsManager | None = None,
+ ) -> None:
+ """Validate and install the shared options manager.
+
+ If no options manager is provided, a new `OptionsManager` is created and
+ stored on the instance.
+
+ Args:
+ options_manager (OptionsManager | None): Optional options manager to reuse.
+
+ Raises:
+ NotAFalyxError: If `options_manager` is provided but is not an `OptionsManager`.
+ """
+ self.options_manager: OptionsManager = options_manager or OptionsManager()
+ if not isinstance(self.options_manager, OptionsManager):
+ raise NotAFalyxError("options_manager must be an instance of OptionsManager.")
+
+ def _register_runtime_options(self) -> None:
+ """Seed default application options and execution namespace values.
+
+ This method ensures that core runtime flags such as prompt behavior,
+ menu visibility, and program display metadata exist in the shared options
+ manager.
+ """
+ if not self.options_manager.get_namespace("root"):
+ self.options_manager.from_mapping(values={}, namespace_name="root")
+ if not self.options_manager.get_namespace("execution"):
+ self.options_manager.from_mapping(values={}, namespace_name="execution")
+
+ if not self.options_manager.has_option("never_prompt"):
+ self.options_manager.set("never_prompt", self._never_prompt, "root")
+
+ if not self.options_manager.has_option("force_confirm"):
+ self.options_manager.set("force_confirm", self._force_confirm, "root")
+
+ if not self.options_manager.has_option("verbose"):
+ self.options_manager.set("verbose", self._verbose, "root")
+
+ if not self.options_manager.has_option("debug_hooks"):
+ self.options_manager.set("debug_hooks", self._debug_hooks, "root")
+
+ if not self.options_manager.has_option("hide_menu_table"):
+ self.options_manager.set("hide_menu_table", self._hide_menu_table)
+
+ if not self.options_manager.has_option("program"):
+ self.options_manager.set("program", self.program)
+
+ if not self.options_manager.has_option("program_style"):
+ self.options_manager.set("program_style", self.program_style)
+
+ @property
+ def completion_names(self) -> list[str]:
+ """Return the visible names exposed for namespace completion.
+
+ The result includes command keys, command aliases, namespace keys,
+ namespace aliases, builtins, and special entries such as history and exit,
+ while deduplicating names case-insensitively.
+
+ Returns:
+ list[str]: Visible completion candidates for this namespace.
+ """
+ names: list[str] = []
+ seen: set[str] = set()
+
+ def add(name: str) -> None:
+ normalized = name.upper().strip()
+ if normalized not in seen:
+ seen.add(normalized)
+ names.append(name)
+
+ for command in self.commands.values():
+ if not command.hidden:
+ add(command.key)
+ for alias in command.aliases:
+ add(alias)
+
+ for namespace in self.namespaces.values():
+ if not namespace.hidden:
+ add(namespace.key)
+ for alias in namespace.aliases:
+ add(alias)
+
+ for command in self.builtins.values():
+ if not command.hidden:
+ add(command.key)
+ for alias in command.aliases:
+ add(alias)
+
+ if self.history_command and not self.history_command.hidden:
+ add(self.history_command.key)
+ for alias in self.history_command.aliases:
+ add(alias)
+
+ add(self.exit_command.key)
+ for alias in self.exit_command.aliases:
+ add(alias)
+
+ return names
+
+ @property
+ def _entry_map(self) -> dict[str, Command | FalyxNamespace]:
+ """Build a case-insensitive lookup map for all resolvable entries.
+
+ The map includes commands, namespaces, builtins, history, and exit
+ entries. Descriptions are also registered for commands and builtins to
+ support friendly lookup behavior.
+
+ Returns:
+ dict[str, Command | FalyxNamespace]: Normalized identifier-to-entry map.
+
+ Raises:
+ CommandAlreadyExistsError: If two distinct entries claim the same
+ normalized identifier.
+ """
+ mapping: dict[str, Command | FalyxNamespace] = {}
+
+ def register(name: str, entry: Command | FalyxNamespace):
norm = name.upper().strip()
if norm in mapping:
existing = mapping[norm]
- if existing is not cmd:
- logger.warning(
- "[alias conflict] '%s' already assigned to '%s'. "
- "Skipping for '%s'.",
- name,
- existing.description,
- cmd.description,
+ if existing is not entry:
+ raise CommandAlreadyExistsError(
+ f"identifier '{norm}' is already registered.\n"
+ f"existing entry: {mapping[norm].key}\n"
+ f"new entry: {entry.key}"
)
else:
- mapping[norm] = cmd
+ mapping[norm] = entry
- for special in [self.exit_command, self.history_command, self.help_command]:
+ for command in self.commands.values():
+ register(command.key, command)
+ for alias in command.aliases:
+ register(alias, command)
+ register(command.description, command)
+
+ for namespace in self.namespaces.values():
+ register(namespace.key, namespace)
+ for alias in namespace.aliases:
+ register(alias, namespace)
+
+ for command in self.builtins.values():
+ register(command.key, command)
+ for alias in command.aliases:
+ register(alias, command)
+ register(command.description, command)
+
+ for special in [self.history_command, self.exit_command]:
if special:
register(special.key, special)
for alias in special.aliases:
register(alias, special)
register(special.description, special)
- for cmd in self.commands.values():
- register(cmd.key, cmd)
- for alias in cmd.aliases:
- register(alias, cmd)
- register(cmd.description, cmd)
return mapping
- def get_title(self) -> str:
- """Returns the string title of the menu."""
- if isinstance(self.title, str):
- return self.title
- elif isinstance(self.title, Markdown):
- return self.title.markup
- return self.title
-
def _get_exit_command(self) -> Command:
- """Returns the back command for the menu."""
+ """Create the default exit command for this namespace.
+
+ The default entry emits a `QuitSignal`, is excluded from history-sensitive
+ behavior, and is rendered with the namespace's shared options manager.
+
+ Returns:
+ Command: Configured exit command instance.
+ """
exit_command = Command(
key="X",
description="Exit",
- action=Action("Exit", action=_noop),
+ action=SignalAction("Exit", QuitSignal()),
aliases=["EXIT", "QUIT"],
style=OneColors.DARK_RED,
simple_help_signature=True,
ignore_in_history=True,
- options_manager=self.options,
+ options_manager=self.options_manager,
program=self.program,
help_text="Exit the program.",
)
@@ -299,60 +716,61 @@ class Falyx:
return exit_command
def _get_history_command(self) -> Command:
- """Returns the history command for the menu."""
- parser = CommandArgumentParser(
- command_key="Y",
- command_description="History",
- command_style=OneColors.DARK_YELLOW,
- aliases=["HISTORY"],
- program=self.program,
- options_manager=self.options,
- )
- parser.add_argument(
- "-n",
- "--name",
- help="Filter by execution name.",
- )
- parser.add_argument(
- "-i",
- "--index",
- type=int,
- help="Filter by execution index (0-based).",
- )
- parser.add_argument(
- "-s",
- "--status",
- choices=["all", "success", "error"],
- default="all",
- help="Filter by execution status (default: all).",
- )
- parser.add_argument(
- "-c",
- "--clear",
- action="store_true",
- help="Clear the Execution History.",
- )
- parser.add_argument(
- "-r",
- "--result-index",
- type=int,
- help="Get the result by index",
- )
- parser.add_argument(
- "-l", "--last-result", action="store_true", help="Get the last result"
- )
- parser.add_tldr_examples(
- [
- ("", "Show the full execution history."),
- ("-n build", "Show history entries for the 'build' command."),
- ("-s success", "Show only successful executions."),
- ("-s error", "Show only failed executions."),
- ("-i 3", "Show the history entry at index 3."),
- ("-r 0", "Show the result or traceback for entry index 0."),
- ("-l", "Show the last execution result."),
- ("-c", "Clear the execution history."),
- ]
- )
+ """Create the built-in execution-history command.
+
+ The returned command wraps `ExecutionRegistry.summary` and includes a
+ purpose-built parser for history filtering, clearing, and result lookup.
+
+ Returns:
+ Command: Configured history command instance.
+ """
+
+ def add_history_arguments(parser: CommandArgumentParser) -> None:
+ parser.add_argument(
+ "-n",
+ "--name",
+ help="Filter by execution name.",
+ )
+ parser.add_argument(
+ "-i",
+ "--index",
+ type=int,
+ help="Filter by execution index (0-based).",
+ )
+ parser.add_argument(
+ "-s",
+ "--status",
+ choices=["all", "success", "error"],
+ default="all",
+ help="Filter by execution status (default: all).",
+ )
+ parser.add_argument(
+ "-c",
+ "--clear",
+ action="store_true",
+ help="Clear the Execution History.",
+ )
+ parser.add_argument(
+ "-r",
+ "--result-index",
+ type=int,
+ help="Get the result by index",
+ )
+ parser.add_argument(
+ "-l", "--last-result", action="store_true", help="Get the last result"
+ )
+ parser.add_tldr_examples(
+ [
+ ("", "Show the full execution history."),
+ ("-n build", "Show history entries for the 'build' command."),
+ ("-s success", "Show only successful executions."),
+ ("-s error", "Show only failed executions."),
+ ("-i 3", "Show the history entry at index 3."),
+ ("-r 0", "Show the result or traceback for entry index 0."),
+ ("-l", "Show the last execution result."),
+ ("-c", "Clear the execution history."),
+ ]
+ )
return Command(
key="Y",
@@ -360,16 +778,23 @@ class Falyx:
aliases=["HISTORY"],
action=Action(name="View Execution History", action=er.summary),
style=OneColors.DARK_YELLOW,
- arg_parser=parser,
+ argument_config=add_history_arguments,
help_text="View the execution history of commands.",
ignore_in_history=True,
- options_manager=self.options,
+ options_manager=self.options_manager,
program=self.program,
)
def get_tip(self) -> str:
- """Returns a random tip for the user about using Falyx."""
- program = f"{self.program} run " if self.is_cli_mode else ""
+ """Return a random usage tip appropriate for the current runtime mode.
+
+ Tips differ slightly between CLI and menu mode so the user sees examples
+ that match the active interface.
+
+ Returns:
+ str: One formatted help tip.
+ """
+ program = f"{self.program} " if self._is_cli_mode else ""
tips = [
f"Use '{program}?[COMMAND]' to preview a command.",
"Every command supports aliases—try abbreviating the name!",
@@ -379,17 +804,17 @@ class Falyx:
f"'{self.program} --never-prompt' to disable all prompts for the [bold italic]entire menu session[/].",
f"Use '{self.program} --verbose' to enable debug logging for a menu session.",
f"'{self.program} --debug-hooks' will trace every before/after hook in action.",
- f"Run commands directly from the CLI: '{self.program} run [COMMAND] [OPTIONS]'.",
+ f"Run commands directly from the CLI: '{self.program} [COMMAND] [OPTIONS]'.",
"All [COMMAND] keys and aliases are case-insensitive.",
]
- if self.is_cli_mode:
+ if self._is_cli_mode:
tips.extend(
[
f"Use '{self.program} help' to list all commands at any time.",
- f"Use '{self.program} --never-prompt run [COMMAND] [OPTIONS]' to disable all prompts for [bold italic]just this command[/].",
- f"Use '{self.program} run --skip-confirm [COMMAND] [OPTIONS]' to skip confirmations.",
- f"Use '{self.program} run --summary [COMMAND] [OPTIONS]' to print a post-run summary.",
- f"Use '{self.program} --verbose run [COMMAND] [OPTIONS]' to enable debug logging for any run.",
+ f"Use '{self.program} --never-prompt [COMMAND] [OPTIONS]' to disable all prompts for [bold italic]just this command[/].",
+ f"Use '{self.program} --skip-confirm [COMMAND] [OPTIONS]' to skip confirmations.",
+ f"Use '{self.program} --summary [COMMAND] [OPTIONS]' to print a post-run summary.",
+ f"Use '{self.program} --verbose [COMMAND] [OPTIONS]' to enable debug logging for any run.",
"Use '--skip-confirm' for automation scripts where no prompts are wanted.",
]
)
@@ -404,163 +829,658 @@ class Falyx:
)
return choice(tips)
- async def _render_help(
- self, tag: str = "", key: str | None = None, tldr: bool = False
- ) -> None:
- """Renders the help menu with command details, usage examples, and tips."""
- if tldr and not key:
- if self.help_command and self.help_command.arg_parser:
- self.help_command.arg_parser.render_tldr()
- self.console.print(f"[bold]tip:[/bold] {self.get_tip()}")
- return None
- if key:
- _, command, args, kwargs = await self.get_command(key, from_help=True)
- if command and tldr and command.arg_parser:
- command.arg_parser.render_tldr()
- self.console.print(f"[bold]tip:[/bold] {self.get_tip()}")
- return None
- elif command and tldr and not command.arg_parser:
- self.console.print(
- f"[bold]No TLDR examples available for '{command.description}'.[/bold]"
+ def _get_command_keys_usage_string(self) -> str:
+ """Build a usage string fragment representing the available command keys.
+
+ This method gathers all visible command and builtin keys, and formats them in a
+ '|' separated string suitable for inclusion in usage text.
+
+ Returns:
+ str: Formatted usage fragment containing available command keys.
+ """
+ keys = [
+ f"[{command.style}]{command.key}[/{command.style}]"
+ for command in self.commands.values()
+ if not command.hidden
+ ]
+ keys.extend(
+ [
+ f"[{namespace.style}]{namespace.key}[/{namespace.style}]"
+ for namespace in self.namespaces.values()
+ if not namespace.hidden
+ ]
+ )
+ keys.extend(
+ [
+ f"[{command.style}]{command.key}[/{command.style}]"
+ for command in self.builtins.values()
+ if not command.hidden
+ ]
+ )
+ if not self._is_cli_mode:
+ if self.history_command and not self.history_command.hidden:
+ keys.append(
+ f"[{self.history_command.style}]{self.history_command.key}[/{self.history_command.style}]"
)
- elif command and command.arg_parser:
- command.arg_parser.render_help()
+ keys.append(
+ f"[{self.exit_command.style}]{self.exit_command.key}[/{self.exit_command.style}]"
+ )
+ return "|".join(keys)
+
+ def _get_usage_fragment(self, invocation_context: InvocationContext) -> str:
+ """Build the default namespace usage fragment for the given context.
+
+ Usage text will contain all commands and namespaces if `simple_usage` is
+ disabled, or a generic placeholder if `simple_usage` is enabled.
+ If `simple_usage` is enabled, the usage fragment is simplified to a generic
+ placeholder format.
+
+ Args:
+ invocation_context (InvocationContext): Routed invocation context for
+ the current help target.
+
+ Returns:
+ str: Escaped usage fragment suitable for Rich output.
+ """
+ has_namespaces = any(not ns.hidden for ns in self.namespaces.values())
+
+ root_flags = " ".join(
+ f"{escape(f"[{flag}]")}" for flag in self.parser.get_flags()
+ )
+
+ if self.simple_usage:
+ target = "command or namespace" if has_namespaces else "command"
+ else:
+ target = self._get_command_keys_usage_string()
+ return f"{root_flags} <{target}> {escape('[args...]')}"
+
+ def _get_usage(
+ self,
+ invocation_context: InvocationContext | None = None,
+ ) -> str:
+ """Build usage information for the current namespace.
+
+ This method builds a usage string based on the current invocation context
+ and renders it to the console with appropriate styling.
+
+ Args:
+ invocation_context (InvocationContext | None): Routed invocation context for
+ the current help target.
+ """
+ invocation_context = invocation_context or self.get_current_invocation_context()
+ usage = self.usage or self._get_usage_fragment(invocation_context)
+ if self._is_cli_mode:
+ return f"[bold]usage:[/bold] {invocation_context.markup_path} [{self.usage_style}]{usage}[/{self.usage_style}]"
+ return f"[bold]usage:[/bold] [{self.usage_style}]{usage}[/{self.usage_style}]"
+
+ def render_usage(
+ self,
+ invocation_context: InvocationContext | None = None,
+ ) -> None:
+ """Public method to render usage information for the current namespace.
+
+ This method is a public wrapper around `_get_usage` that can be called
+ from commands or hooks to display usage information in the current context.
+
+ Args:
+ invocation_context (InvocationContext | None): Routed invocation context for
+ the current help target.
+ """
+ usage = self._get_usage(invocation_context)
+ console.print(usage)
+
+ async def _render_command_tldr(
+ self,
+ command: Command,
+ invocation_context: InvocationContext | None = None,
+ ) -> None:
+ """Render TLDR examples for a resolved command.
+
+ This helper validates that the supplied entry is a command, delegates TLDR
+ rendering to that command, and optionally appends a random usage tip.
+
+ Args:
+ command (Command): Command whose TLDR output should be shown.
+ invocation_context (InvocationContext | None): Optional routed invocation
+ context used to scope the rendered usage path.
+ """
+ if command.render_tldr(invocation_context):
+ if self.enable_help_tips:
self.console.print(f"[bold]tip:[/bold] {self.get_tip()}")
- return None
- elif command and not command.arg_parser:
+ else:
+ print_error(f"No TLDR examples available for '{command.description}'.")
+
+ async def _render_command_help(
+ self,
+ command: Command,
+ tldr: bool = False,
+ invocation_context: InvocationContext | None = None,
+ ) -> None:
+ """Render detailed help or TLDR output for a resolved command.
+
+ Args:
+ command (Command): Target command to render.
+ tldr (bool): When `True`, render TLDR output instead of full help.
+ invocation_context (InvocationContext | None): Optional routed invocation
+ context used to scope the rendered usage path.
+ """
+ if tldr:
+ await self._render_command_tldr(command, invocation_context)
+ elif command.render_help(invocation_context):
+ if self.enable_help_tips:
+ self.console.print(f"\n[bold]tip:[/bold] {self.get_tip()}")
+ else:
+ print_error(f"No detailed help available for '{command.description}'.")
+
+ async def _render_tag_help(self, tag: str) -> None:
+ """Render all visible commands associated with a tag.
+
+ Matching is case-insensitive and only searches user-registered commands,
+ not namespaces or builtins.
+
+ Args:
+ tag (str): Tag name to filter by.
+ """
+ tag_lower = tag.lower()
+ self.console.print(f"[bold]{tag_lower}:[/bold]")
+ commands = [
+ command
+ for command in self.commands.values()
+ if any(tag_lower == tag.lower() for tag in command.tags)
+ ]
+ if not commands:
+ self.console.print(f"'{tag}'... Nothing to show here")
+ return None
+ for command in commands:
+ usage, description, _ = command.help_signature
+ self.console.print(
+ Padding(
+ Panel(usage, expand=False, title=description, title_align="left"),
+ (0, 2),
+ )
+ )
+ if self.enable_help_tips:
+ self.console.print(f"[bold]tip:[/bold] {self.get_tip()}")
+
+ async def _render_menu_help(self, invocation_context: InvocationContext) -> None:
+ """Render the interactive menu-style help view for this namespace.
+
+ The menu help view displays user commands plus the special help, history,
+ and exit entries using panel-based Rich rendering.
+ """
+ self.render_usage(invocation_context)
+ if self.description:
+ self.console.print(
+ f"\n[{self.description_style}]{self.description}[/{self.description_style}]"
+ )
+
+ # TODO: implement self.parser.render_options_help() and include it here if options are registered at the namespace level
+ self.console.print("\n[bold]global options:[/bold]")
+ for option in self.parser.get_options():
+ self.console.print(f" {option.format_for_help():<22}{option.help}")
+
+ self.console.print("\n[bold]builtin commands:[/bold]")
+ for command in self.builtins.values():
+ usage, description, _ = command.help_signature
+ self.console.print(
+ Padding(
+ Panel(usage, expand=False, title=description, title_align="left"),
+ (0, 2),
+ )
+ )
+ if self.history_command:
+ usage, description, _ = self.history_command.help_signature
+ self.console.print(
+ Padding(
+ Panel(usage, expand=False, title=description, title_align="left"),
+ (0, 2),
+ )
+ )
+ usage, description, _ = self.exit_command.help_signature
+ self.console.print(
+ Padding(
+ Panel(usage, expand=False, title=description, title_align="left"),
+ (0, 2),
+ )
+ )
+ if self.namespaces:
+ self.console.print("\n[bold]namespaces:[/bold]")
+ for namespace in self.namespaces.values():
+ usage, description, _ = namespace.get_help_signature(invocation_context)
self.console.print(
- f"[bold]No detailed help available for '{command.description}'.[/bold]"
+ Padding(
+ Panel(usage, expand=False, title=description, title_align="left"),
+ (0, 2),
+ )
+ )
+
+ if self.commands:
+ self.console.print("\n[bold]commands:[/bold]")
+ for command in self.commands.values():
+ usage, description, tag = command.help_signature
+ self.console.print(
+ Padding(
+ Panel(
+ usage,
+ expand=False,
+ title=description,
+ title_align="left",
+ subtitle=tag,
+ ),
+ (0, 2),
+ )
+ )
+
+ if self.epilog:
+ self.console.print(f"\n{self.epilog}", style=self.epilog_style)
+ if self.enable_help_tips:
+ self.console.print(f"\n[bold]tip:[/bold] {self.get_tip()}")
+
+ async def _render_namespace_tldr_help(
+ self, invocation_context: InvocationContext
+ ) -> None:
+ """Render namespace-level TLDR examples for the current scope.
+
+ This prints usage, optional namespace description, and all registered TLDR
+ examples using the routed invocation path supplied by the context.
+
+ Args:
+ invocation_context (InvocationContext): Routed invocation context for the
+ namespace being rendered.
+ """
+ if not self.parser.tldr_option:
+ self.console.print(
+ f"[bold]No TLDR examples available for '{self.title}'.[/bold]"
+ )
+ return None
+ self.render_usage(invocation_context)
+ prefix = invocation_context.markup_path
+ if self.description:
+ self.console.print(
+ f"\n[{self.description_style}]{self.description}[/{self.description_style}]"
+ )
+ self.console.print("\n[bold]examples:[/bold]")
+ for example in self.parser._tldr_examples:
+ entry, suggestions = self.resolve_entry(example.entry_key)
+ if not entry:
+ raise EntryNotFoundError(
+ unknown_name=example.entry_key,
+ suggestions=suggestions,
+ message_context="TLDR example",
+ )
+ command = f"[{entry.style}]{example.entry_key}[/{entry.style}]"
+ usage = f"{prefix} {command} {example.usage.strip()}"
+ description = example.description.strip()
+ block = f"[bold]{usage}[/bold]"
+ self.console.print(
+ Padding(
+ Panel(block, expand=False, title=description, title_align="left"),
+ (0, 2),
+ )
+ )
+
+ async def render_namespace_help(
+ self,
+ invocation_context: InvocationContext | None = None,
+ tldr: bool = False,
+ ) -> None:
+ """Render help for the current namespace.
+
+ Depending on the active mode and flags, this dispatches to namespace TLDR,
+ menu-style help, or CLI-style help rendering.
+
+ Args:
+ invocation_context (InvocationContext | None): Optional routed invocation
+ context. When omitted, a fresh root context is created.
+ tldr (bool): Whether to render namespace TLDR output instead of standard help.
+ """
+ invocation_context = invocation_context or self.get_current_invocation_context()
+ if tldr:
+ await self._render_namespace_tldr_help(invocation_context)
+ elif invocation_context.mode is FalyxMode.MENU:
+ await self._render_menu_help(invocation_context)
+ else:
+ await self._render_cli_help(invocation_context)
+
+ async def _render_cli_help(self, invocation_context: InvocationContext) -> None:
+ """Render the CLI-style help view for this namespace.
+
+ The output includes usage, description, global options, builtin commands,
+ user commands, and optional epilog content.
+
+ Args:
+ invocation_context (InvocationContext): Routed invocation context used to
+ render the current invocation path.
+ """
+ self.render_usage(invocation_context)
+ if self.description:
+ self.console.print(
+ f"\n[{self.description_style}]{self.description}[/{self.description_style}]"
+ )
+ # TODO: implement self.parser.render_options_help() and include it here if options are registered at the namespace level
+ self.console.print("\n[bold]global options:[/bold]")
+ for option in self.parser.get_options():
+ self.console.print(f" {option.format_for_help():<22}{option.help}")
+
+ self.console.print("\n[bold]builtin commands:[/bold]")
+ for command in self.builtins.values():
+ builtin_alias = Text(command.primary_alias, style=command.style)
+
+ line = Text(" ")
+ line.append(builtin_alias)
+ line.pad_right(24 - len(line.plain))
+ line.append(command.help_text)
+
+ self.console.print(line)
+ if self.namespaces:
+ self.console.print("\n[bold]namespaces:[/bold]")
+ for namespace in self.namespaces.values():
+ line = Text(" ")
+ line.append(namespace.key, style=namespace.style)
+ for alias in namespace.aliases:
+ line.append(" | ", style="dim")
+ line.append(alias, style=namespace.style)
+ line.pad_right(24 - len(line.plain))
+ line.append(namespace.description or "")
+ self.console.print(line)
+ if self.commands:
+ self.console.print("\n[bold]commands:[/bold]")
+ for command in self.commands.values():
+ line = Text(" ")
+ line.append(command.key, style=command.style)
+ for alias in command.aliases:
+ line.append(" | ", style="dim")
+ line.append(alias, style=command.style)
+ line.pad_right(24 - len(line.plain))
+ line.append(command.help_text or command.description)
+ self.console.print(line)
+ if self.epilog:
+ self.console.print(f"\n{self.epilog}", style=self.epilog_style)
+ if self.enable_help_tips:
+ self.console.print(f"\n[bold]tip:[/bold] {self.get_tip()}")
+
+ def _help_target_base_context(
+ self, invocation_context: InvocationContext
+ ) -> InvocationContext:
+ """Normalize help context before rendering a nested target.
+
+ This strips the trailing help-command segment from the routed path when the
+ help command itself is the active entry, preventing duplicated invocation
+ paths in nested help output.
+
+ Args:
+ invocation_context (InvocationContext): Routed help context to normalize.
+
+ Returns:
+ InvocationContext: Adjusted context for downstream help rendering.
+ """
+ if not invocation_context.typed_path:
+ return invocation_context
+
+ last_token = invocation_context.typed_path[-1]
+ entry, _ = self.resolve_entry(last_token)
+
+ if entry is self.help_command:
+ return invocation_context.without_last_path_segment()
+
+ return invocation_context
+
+ async def render_help(
+ self,
+ tag: str = "",
+ key: str | None = None,
+ tldr: bool = False,
+ namespace_tldr: bool = False,
+ invocation_context: InvocationContext | None = None,
+ ) -> None:
+ """Render help for a namespace, tag, or specific entry.
+
+ This is the main help dispatcher for `Falyx`. It can render:
+
+ - namespace help for the current scope
+ - namespace TLDR output
+ - tag-filtered command help
+ - command help for a specific key
+ - namespace help for a specific nested namespace
+
+ Args:
+ tag (str): Optional tag filter for command help.
+ key (str | None): Optional command or namespace identifier to render directly.
+ tldr (bool): Whether targeted command help should use TLDR output.
+ namespace_tldr (bool): Whether top-level namespace help should use TLDR output.
+ invocation_context (InvocationContext | None): Optional routed invocation context.
+
+ Raises:
+ EntryNotFoundError: If `key` is provided but cannot be resolved to a known command
+ or namespace in this scope.
+ """
+ context = invocation_context or self.get_current_invocation_context()
+ if key:
+ base_context = self._help_target_base_context(context)
+
+ entry, suggestions = self.resolve_entry(key)
+ if isinstance(entry, Command):
+ await self._render_command_help(
+ command=entry,
+ tldr=tldr,
+ invocation_context=base_context.with_path_segment(
+ key, style=entry.style
+ ),
+ )
+ elif isinstance(entry, FalyxNamespace):
+ await entry.namespace.render_namespace_help(
+ invocation_context=base_context.with_path_segment(
+ key, style=entry.style
+ ),
+ tldr=tldr,
)
else:
- self.console.print(f"[bold]No command found for '{key}'.[/bold]")
- if tag:
- tag_lower = tag.lower()
- self.console.print(f"[bold]{tag_lower}:[/bold]")
- commands = [
- command
- for command in self.commands.values()
- if any(tag_lower == tag.lower() for tag in command.tags)
- ]
- if not commands:
- self.console.print(f"'{tag}'... Nothing to show here")
- return None
- for command in commands:
- usage, description, _ = command.help_signature
- self.console.print(
- Padding(
- Panel(usage, expand=False, title=description, title_align="left"),
- (0, 2),
- )
+ await self.render_namespace_help(base_context)
+ raise EntryNotFoundError(
+ unknown_name=key,
+ suggestions=suggestions,
)
- self.console.print(f"[bold]tip:[/bold] {self.get_tip()}")
return None
-
- self.console.print("[bold]help:[/bold]")
- for command in self.commands.values():
- usage, description, tag = command.help_signature
- self.console.print(
- Padding(
- Panel(
- usage,
- expand=False,
- title=description,
- title_align="left",
- subtitle=tag,
- ),
- (0, 2),
- )
+ elif tldr:
+ await self._render_command_help(
+ self.help_command,
+ tldr,
+ invocation_context=context,
)
- if self.help_command:
- usage, description, _ = self.help_command.help_signature
- self.console.print(
- Padding(
- Panel(usage, expand=False, title=description, title_align="left"),
- (0, 2),
- )
- )
- if not self.is_cli_mode:
- if self.history_command:
- usage, description, _ = self.history_command.help_signature
- self.console.print(
- Padding(
- Panel(usage, expand=False, title=description, title_align="left"),
- (0, 2),
- )
- )
- usage, description, _ = self.exit_command.help_signature
- self.console.print(
- Padding(
- Panel(usage, expand=False, title=description, title_align="left"),
- (0, 2),
- )
- )
- self.console.print(f"[bold]tip:[/bold] {self.get_tip()}")
+ elif tag:
+ await self._render_tag_help(tag)
+ else:
+ await self.render_namespace_help(context, namespace_tldr)
def _get_help_command(self) -> Command:
- """Returns the help command for the menu."""
- parser = CommandArgumentParser(
- command_key="H",
- command_description="Help",
- command_style=OneColors.LIGHT_YELLOW,
- aliases=["HELP", "?"],
- program=self.program,
- options_manager=self.options,
- _is_help_command=True,
- )
- parser.add_argument(
- "-t",
- "--tag",
- nargs="?",
- default="",
- help="Optional tag to filter commands by.",
- )
- parser.add_argument(
- "-k",
- "--key",
- nargs="?",
- default=None,
- help="Optional command key or alias to get detailed help for.",
- )
- parser.add_tldr_examples(
- [
- ("", "Show all commands."),
- ("-k [COMMAND]", "Show detailed help for a specific command."),
- ("-Tk [COMMAND]", "Show quick usage examples for a specific command."),
- ("-T", "Show these quick usage examples."),
- ("-t [TAG]", "Show commands with the specified tag."),
- ]
- )
+ """Create the built-in help command for this namespace.
+
+ The returned command wraps `render_help()` and installs a dedicated parser
+ that supports tag filtering, targeted key help, and TLDR behavior.
+
+ Returns:
+ Command: Configured help command instance.
+ """
+
+ def add_help_arguments(parser: CommandArgumentParser):
+ parser.mark_as_help_command()
+ parser.add_argument(
+ "--namespace-tldr",
+ "-N",
+ action="store_true",
+ help="Show TLDR examples for the namespace instead of full help.",
+ )
+ parser.add_argument(
+ "-t",
+ "--tag",
+ nargs="?",
+ default="",
+ help="Optional tag to filter commands by.",
+ )
+ parser.add_argument(
+ "-k",
+ "--key",
+ nargs="?",
+ default=None,
+ help="Optional command key or alias to get detailed help for.",
+ )
+ parser.add_tldr_examples(
+ [
+ ("", "Show all commands."),
+ ("-k [COMMAND]", "Show detailed help for a specific command."),
+ (
+ "-Tk [COMMAND]",
+ "Show quick usage examples for a specific command.",
+ ),
+ ("-T", "Show these quick usage examples."),
+ ("-t [TAG]", "Show commands with the specified tag."),
+ ("-N", "Show TLDR examples for the current namespace."),
+ ]
+ )
+ tldr_argument = parser.get_argument("tldr")
+ if tldr_argument:
+ tldr_argument.help = "Show TLDR examples instead of full help."
+
return Command(
key="H",
aliases=["HELP", "?"],
description="Help",
help_text="Show this help menu.",
- action=Action("Help", self._render_help),
+ action=Action("Help", self.render_help),
style=OneColors.LIGHT_YELLOW,
- arg_parser=parser,
+ argument_config=add_help_arguments,
ignore_in_history=True,
- options_manager=self.options,
+ options_manager=self.options_manager,
program=self.program,
)
+ async def _preview(self, key: str) -> None:
+ """Render a preview for a specific command key.
+
+ Namespaces are rejected because preview is only meaningful at the leaf
+ command boundary.
+
+ Args:
+ key (str): Command key or alias to preview.
+ """
+ entry, suggestions = self.resolve_entry(key)
+ if isinstance(entry, FalyxNamespace):
+ raise FalyxError("preview mode is only supported for commands.")
+ elif isinstance(entry, Command):
+ await entry.preview()
+ else:
+ raise EntryNotFoundError(
+ unknown_name=key,
+ suggestions=suggestions,
+ )
+
+ def _get_preview_command(self) -> Command:
+ """Create the built-in preview command.
+
+ The preview command accepts a command key or alias and delegates to
+ `_preview()`.
+
+ Returns:
+ Command: Configured preview command instance.
+ """
+
+ def add_preview_argument(parser: CommandArgumentParser):
+ parser.add_argument(
+ "key",
+ help="The key or alias of the command to preview.",
+ )
+ parser.add_tldr_examples(
+ [
+ ("", "Preview the execution of a specific command."),
+ ]
+ )
+
+ preview_command = Command(
+ key="PVW",
+ description="Preview",
+ aliases=["PREVIEW"],
+ action=Action("Preview", self._preview),
+ style=OneColors.GREEN,
+ options_manager=self.options_manager,
+ program=self.program,
+ help_text="Preview the execution of a command without running it.",
+ argument_config=add_preview_argument,
+ )
+ return preview_command
+
+ async def _render_version(self) -> None:
+ """Render the program version string for this namespace."""
+ self.console.print(f"[{self.version_style}]{self.program} v{self.version}[/]")
+
+ def _get_version_command(self) -> Command:
+ """Create the built-in version command.
+
+ Returns:
+ Command: Configured version command instance.
+ """
+ version_command = Command(
+ key="VER",
+ description="Version",
+ aliases=["VERSION"],
+ action=Action("Version", self._render_version),
+ style=self.version_style,
+ ignore_in_history=True,
+ options_manager=self.options_manager,
+ program=self.program,
+ help_text=f"Show the {self.program} version.",
+ )
+ if version_command.arg_parser:
+ version_command.arg_parser.add_tldr_examples(
+ [("", f"Show the {self.program} version.")]
+ )
+ return version_command
+
+ def _add_builtin(self, command: Command) -> None:
+ """Register a builtin command in the current namespace.
+
+ Args:
+ command (Command): Builtin command to register.
+
+ Raises:
+ CommandAlreadyExistsError: If the builtin key or aliases collide with an
+ existing identifier.
+ """
+ self._validate_command_aliases(command.key, command.aliases)
+ self.builtins[command.key.upper()] = command
+ _ = self._entry_map
+
+ def _register_default_builtins(self) -> None:
+ """Register the default help, preview, and version builtins."""
+ self._add_builtin(self.help_command)
+ self._add_builtin(self._get_preview_command())
+ self._add_builtin(self._get_version_command())
+
def _get_completer(self) -> FalyxCompleter:
- """Completer to provide auto-completion for the menu commands."""
+ """Create the Prompt Toolkit completer for this namespace.
+
+ Returns:
+ FalyxCompleter: Routing-aware completer bound to this `Falyx` instance.
+ """
return FalyxCompleter(self)
def _get_validator_error_message(self) -> str:
- """Validator to check if the input is a valid command."""
- keys = {self.exit_command.key.upper()}
- keys.update({alias.upper() for alias in self.exit_command.aliases})
- if self.history_command:
- keys.add(self.history_command.key.upper())
- keys.update({alias.upper() for alias in self.history_command.aliases})
- if self.help_command:
- keys.add(self.help_command.key.upper())
- keys.update({alias.upper() for alias in self.help_command.aliases})
+ """Build the validation error message shown by the prompt session.
- for cmd in self.commands.values():
- keys.add(cmd.key.upper())
- keys.update({alias.upper() for alias in cmd.aliases})
+ The message lists all currently visible entry keys and aliases that may be
+ invoked from the current namespace.
+
+ Returns:
+ str: User-facing validation error text.
+ """
+ visible = self._iter_visible_entries(
+ include_help=True,
+ include_history=True,
+ include_exit=True,
+ )
+ keys = {entry.key.upper() for entry in visible}
+ for entry in visible:
+ for alias in entry.aliases:
+ keys.add(alias.upper())
commands_str = ", ".join(sorted(keys))
@@ -572,33 +1492,37 @@ class Falyx:
return error_message
def _invalidate_prompt_session_cache(self):
- """Forces the prompt session to be recreated on the next access."""
+ """Drop any cached prompt session so UI changes take effect.
+
+ This is used when bottom-bar configuration or other prompt-session state
+ changes and a fresh `PromptSession` must be built on next access.
+ """
if hasattr(self, "prompt_session"):
del self.prompt_session
self._prompt_session = None
- def add_help_command(self):
- """Adds a help command to the menu if it doesn't already exist."""
- if not self.help_command:
- self.help_command = self._get_help_command()
-
- def add_history_command(self):
- """Adds a history command to the menu if it doesn't already exist."""
- if not self.history_command:
- self.history_command = self._get_history_command()
-
@property
def bottom_bar(self) -> BottomBar | str | Callable[[], Any] | None:
- """Returns the bottom bar for the menu."""
+ """Return the configured bottom-bar definition for menu mode."""
return self._bottom_bar
@bottom_bar.setter
def bottom_bar(self, bottom_bar: BottomBar | str | Callable[[], Any] | None) -> None:
- """Sets the bottom bar for the menu."""
+ """Install or normalize the bottom-bar configuration.
+
+ `None` produces a default `BottomBar`. A `BottomBar` instance is rebound to
+ this namespace's key bindings. Strings and callables are stored directly as
+ alternate toolbar renderers.
+
+ Args:
+ bottom_bar (BottomBar | str | Callable[[], Any] | None): Toolbar
+ configuration to install.
+
+ Raises:
+ FalyxError: If the value is not a supported bottom-bar type.
+ """
if bottom_bar is None:
- self._bottom_bar: BottomBar | str | Callable[[], Any] = BottomBar(
- self.columns, self.key_bindings
- )
+ self._bottom_bar = BottomBar(self.columns, self.key_bindings)
elif isinstance(bottom_bar, BottomBar):
bottom_bar.key_bindings = self.key_bindings
self._bottom_bar = bottom_bar
@@ -606,13 +1530,18 @@ class Falyx:
self._bottom_bar = bottom_bar
else:
raise FalyxError(
- "Bottom bar must be a string, callable, None, or BottomBar instance."
+ "bottom_bar must be a string, callable, None, or BottomBar instance."
)
self._invalidate_prompt_session_cache()
def _get_bottom_bar_render(self) -> Callable[[], Any] | str | None:
- """Returns the bottom bar for the menu."""
- if isinstance(self.bottom_bar, BottomBar) and self.bottom_bar._named_items:
+ """Return the actual toolbar renderer used by the prompt session.
+
+ Returns:
+ Callable[[], Any] | str | None: Render callable, static toolbar string,
+ or `None` when no toolbar should be shown.
+ """
+ if isinstance(self.bottom_bar, BottomBar) and self.bottom_bar.has_items:
return self.bottom_bar.render
elif callable(self.bottom_bar):
return self.bottom_bar
@@ -622,7 +1551,15 @@ class Falyx:
@cached_property
def prompt_session(self) -> PromptSession:
- """Returns the prompt session for the menu."""
+ """Create and cache the interactive prompt session.
+
+ The prompt session wires together completion, validation, history,
+ bottom-toolbar rendering, placeholder content, and quit behavior for menu
+ mode.
+
+ Returns:
+ PromptSession: Configured prompt session for interactive input.
+ """
if self._prompt_session is None:
placeholder = self.build_placeholder_menu()
self._prompt_session = PromptSession(
@@ -642,11 +1579,23 @@ class Falyx:
return self._prompt_session
def register_all_hooks(self, hook_type: HookType, hooks: Hook | list[Hook]) -> None:
- """Registers hooks for all commands in the menu and actions recursively."""
+ """Register a hook across the namespace and all nested actions.
+
+ Hooks are attached to the application hook manager, every registered
+ command, and any nested `BaseAction` or nested `Falyx` runtime reachable
+ through command actions.
+
+ Args:
+ hook_type (HookType): Lifecycle slot to register against.
+ hooks (Hook | list[Hook]): Single hook or list of hooks to apply recursively.
+
+ Raises:
+ InvalidHookError: If any supplied hook is not callable.
+ """
hook_list = hooks if isinstance(hooks, list) else [hooks]
for hook in hook_list:
if not callable(hook):
- raise InvalidActionError("Hook must be a callable.")
+ raise InvalidHookError("hooks must be a callable.")
self.hooks.register(hook_type, hook)
for command in self.commands.values():
command.hooks.register(hook_type, hook)
@@ -656,36 +1605,58 @@ class Falyx:
command.action.register_hooks_recursively(hook_type, hook)
def register_all_with_debug_hooks(self) -> None:
- """Registers debug hooks for all commands in the menu and actions recursively."""
+ """Install the standard debug hook set across all commands and actions."""
self.register_all_hooks(HookType.BEFORE, log_before)
self.register_all_hooks(HookType.ON_SUCCESS, log_success)
self.register_all_hooks(HookType.ON_ERROR, log_error)
self.register_all_hooks(HookType.AFTER, log_after)
- def debug_hooks(self) -> None:
- """Logs the names of all hooks registered for the menu and its commands."""
- logger.debug("Menu-level hooks:\n%s", str(self.hooks))
+ def _validate_command_aliases(self, key: str, aliases: list[str] | None) -> None:
+ """Validate that a new command or namespace identifier set is unique.
- for key, command in self.commands.items():
- logger.debug("[Command '%s'] hooks:\n%s", key, str(command.hooks))
+ Validation is case-insensitive and checks the proposed key and aliases
+ against existing commands, builtins, history, and exit entries.
- def _validate_command_key(self, key: str) -> None:
- """Validates the command key to ensure it is unique."""
+ Args:
+ key (str): Proposed primary key.
+ aliases (list[str] | None): Proposed aliases for the same entry.
+
+ Raises:
+ CommandAlreadyExistsError: If duplicates or collisions are found.
+ """
key = key.upper()
- collisions = []
+ aliases = [alias.upper() for alias in (aliases or [])]
- if key in self.commands:
- collisions.append("command")
- if key == self.exit_command.key.upper():
- collisions.append("back command")
- if self.history_command and key == self.history_command.key.upper():
- collisions.append("history command")
- if self.help_command and key == self.help_command.key.upper():
- collisions.append("help command")
+ if len(set(aliases)) != len(aliases):
+ raise CommandAlreadyExistsError("duplicate aliases provided.")
+
+ if key in aliases:
+ raise CommandAlreadyExistsError("command key cannot also be an alias.")
+
+ existing_names = set()
+
+ def collect_names(command: Command):
+ existing_names.add(command.key.upper())
+ existing_names.update(alias.upper() for alias in command.aliases)
+
+ for command in self.commands.values():
+ collect_names(command)
+
+ for command in self.builtins.values():
+ collect_names(command)
+
+ collect_names(self.exit_command)
+
+ if self.history_command:
+ collect_names(self.history_command)
+
+ new_names = {key, *aliases}
+
+ collisions = new_names.intersection(existing_names)
if collisions:
raise CommandAlreadyExistsError(
- f"Command key '{key}' conflicts with existing {', '.join(collisions)}."
+ f"command identifiers {sorted(collisions)} already exist."
)
def update_exit_command(
@@ -699,11 +1670,28 @@ class Falyx:
confirm_message: str = "Are you sure?",
help_text: str = "Exit the program.",
) -> None:
- """Updates the back command of the menu."""
- self._validate_command_key(key)
- action = action or Action(description, action=_noop)
+ """Replace the namespace exit command with a custom one.
+
+ This is commonly used by submenus to swap the default exit behavior for a
+ back-navigation command.
+
+ Args:
+ key (str): New command key.
+ description (str): User-facing description.
+ aliases (list[str] | None): Optional aliases for the exit command.
+ action (Callable[..., Any] | None): Optional callable to execute. Defaults to raising `QuitSignal`.
+ style (str): Rich style used for menu/help rendering.
+ confirm (bool): Whether the command should require confirmation.
+ confirm_message (str): Confirmation prompt text.
+ help_text (str): Help text shown in command listings and help output.
+
+ Raises:
+ InvalidActionError: If the supplied action is not callable.
+ """
+ self._validate_command_aliases(key, aliases)
+ action = action or SignalAction(description, QuitSignal())
if not callable(action):
- raise InvalidActionError("Action must be a callable.")
+ raise InvalidActionError("action must be a callable.")
self.exit_command = Command(
key=key,
description=description,
@@ -713,7 +1701,7 @@ class Falyx:
confirm=confirm,
confirm_message=confirm_message,
ignore_in_history=True,
- options_manager=self.options,
+ options_manager=self.options_manager,
program=self.program,
help_text=help_text,
)
@@ -721,15 +1709,52 @@ class Falyx:
self.exit_command.arg_parser.add_tldr_examples([("", help_text)])
def add_submenu(
- self, key: str, description: str, submenu: Falyx, *, style: str = OneColors.CYAN
+ self,
+ key: str,
+ description: str,
+ submenu: Falyx,
+ *,
+ style: str | None = None,
+ aliases: list[str] | None = None,
+ help_text: str = "",
+ hidden: bool = False,
) -> None:
- """Adds a submenu to the menu."""
+ """Register a nested `Falyx` instance as a namespace entry.
+
+ The submenu becomes part of routing, completion, and help output in the
+ current namespace. When the submenu still uses the default exit command, it
+ is converted to a back command automatically.
+
+ Args:
+ key (str): Namespace key used to enter the submenu.
+ description (str): User-facing namespace description.
+ submenu (Falyx): Nested `Falyx` instance to register.
+ style (StyleType | None): Optional style override for the namespace entry.
+ aliases (list[str] | None): Optional aliases for the namespace.
+ help_text (str): Optional help text for namespace listings.
+ hidden (bool): Where the namespace should be omitted from visible menus and
+ help listings.
+
+ Raises:
+ NotAFalyxError: If `submenu` is not a `Falyx` instance.
+ """
if not isinstance(submenu, Falyx):
raise NotAFalyxError("submenu must be an instance of Falyx.")
- self._validate_command_key(key)
- self.add_command(
- key, description, submenu.menu, style=style, simple_help_signature=True
+
+ self._validate_command_aliases(key, aliases)
+
+ entry = FalyxNamespace(
+ key=key,
+ description=description,
+ namespace=submenu,
+ aliases=aliases or [],
+ help_text=help_text or f"Open the {description} namespace.",
+ style=style or submenu.program_style,
+ hidden=hidden,
)
+
+ self.namespaces[key] = entry
+
if submenu.exit_command.key == "X":
submenu.update_exit_command(
key="B",
@@ -738,24 +1763,56 @@ class Falyx:
help_text="Go back to the previous menu.",
)
- def add_commands(self, commands: list[Command] | list[dict]) -> None:
- """Adds a list of Command instances or config dicts."""
+ def add_commands(self, commands: list[Command] | list[dict]) -> list[Command]:
+ """Register multiple commands from instances or config dictionaries.
+
+ Args:
+ commands (list[Command] | list[dict]): Sequence of `Command` objects or
+ `add_command()` keyword dictionaries.
+
+ Returns:
+ list[Command]: List of registered `Command` instances.
+
+ Raises:
+ FalyxError: If an element is neither a `Command` nor a configuration
+ dictionary.
+ """
+ added_commands = []
for command in commands:
if isinstance(command, dict):
- self.add_command(**command)
+ added_commands.append(self.add_command(**command))
elif isinstance(command, Command):
- self.add_command_from_command(command)
+ added_commands.append(self.add_command_from_command(command))
else:
raise FalyxError(
- "Command must be a dictionary or an instance of Command."
+ "command must be a dictionary or an instance of Command."
)
+ return added_commands
- def add_command_from_command(self, command: Command) -> None:
- """Adds a command to the menu from an existing Command object."""
+ def add_command_from_command(self, command: Command) -> Command:
+ """Registers a clone of the provided command and returns the bound clone
+ owned by this namespace.
+
+ Args:
+ command (Command): Preconstructed command to add to this namespace.
+
+ Returns:
+ Command: The newly registered clone of the command instance bound to
+ this namespace.
+
+ Raises:
+ FalyxError: If `command` is not a `Command`.
+ """
if not isinstance(command, Command):
raise FalyxError("command must be an instance of Command.")
- self._validate_command_key(command.key)
- self.commands[command.key] = command
+ self._validate_command_aliases(command.key, command.aliases)
+ bound_command = command.clone_with_overrides(
+ options_manager=self.options_manager,
+ program=self.program,
+ )
+ self.commands[command.key] = bound_command
+ _ = self._entry_map
+ return bound_command
def add_command(
self,
@@ -792,31 +1849,79 @@ class Falyx:
arg_parser: CommandArgumentParser | None = None,
arguments: list[dict[str, Any]] | None = None,
argument_config: Callable[[CommandArgumentParser], None] | None = None,
+ 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,
ignore_in_history: bool = False,
) -> Command:
- """Adds an command to the menu, preventing duplicates."""
- self._validate_command_key(key)
+ """Build and register a new command in the current namespace.
- if arg_parser:
- if not isinstance(arg_parser, CommandArgumentParser):
- raise NotAFalyxError(
- "arg_parser must be an instance of CommandArgumentParser."
- )
- arg_parser = arg_parser
+ This is the main command-registration API for `Falyx`. It forwards the
+ supplied configuration to `Command.build()`, injects shared runtime state,
+ validates identifier uniqueness, and stores the resulting command.
- command = Command(
+ Args:
+ key (str): Primary command key.
+ description (str): User-facing command description.
+ action (BaseAction | Callable[..., Any]): Underlying action or callable executed by the command.
+ args (tuple): Static positional arguments bound to the command.
+ kwargs (dict[str, Any] | None): Static keyword arguments bound to the command.
+ hidden (bool): Whether the command should be omitted from menu/help listings.
+ aliases (list[str] | None): Optional alternate invocation names.
+ help_text (str): Short help text shown in listings.
+ help_epilog (str): Extended help text shown in command help.
+ style (str): Rich style used for display.
+ confirm (bool): Whether confirmation should be required before execution.
+ confirm_message (str): Confirmation prompt text.
+ preview_before_confirm (bool): Whether preview should run before confirmation.
+ spinner (bool): Whether spinner hooks should be enabled.
+ spinner_message (str): Spinner label.
+ spinner_type (str): Rich spinner preset name.
+ spinner_style (str): Rich style for spinner output.
+ spinner_speed (float): Spinner speed multiplier.
+ hooks (HookManager | None): Optional command hook manager.
+ before_hooks (list[Callable] | None): Optional before hooks.
+ success_hooks (list[Callable] | None): Optional success hooks.
+ error_hooks (list[Callable] | None): Optional error hooks.
+ after_hooks (list[Callable] | None): Optional after hooks.
+ teardown_hooks (list[Callable] | None): Optional teardown hooks.
+ tags (list[str] | None): Optional tag labels for grouping and help filtering.
+ logging_hooks (bool): Whether debug hooks should be enabled.
+ retry (bool): Whether retry behavior should be enabled.
+ retry_all (bool): Whether retry should be applied recursively to nested actions.
+ retry_policy (RetryPolicy | None): Retry policy override.
+ arg_parser (CommandArgumentParser | None): Optional explicit command argument parser.
+ arguments (list[dict[str, Any]] | None): Optional declarative argument definitions.
+ argument_config (Callable[[CommandArgumentParser], None] | None): Optional callback that populates the parser.
+ execution_options (list[ExecutionOption | str] | None): Optional execution-level options to enable.
+ custom_parser (ArgParserProtocol | None): Optional parser override for full custom argument parsing.
+ 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 argument inference should run automatically.
+ arg_metadata (dict[str, str | dict[str, Any]] | None): Optional metadata used during argument inference.
+ simple_help_signature (bool): Whether command listings should use compact help.
+ ignore_in_history (bool): Whether this command should be ignored by history-aware
+ result tracking.
+
+ Returns:
+ Command: The newly built and registered command.
+ """
+ self._validate_command_aliases(key, aliases)
+
+ command = Command.build(
key=key,
description=description,
action=action,
args=args,
- kwargs=kwargs if kwargs else {},
+ kwargs=kwargs,
hidden=hidden,
- aliases=aliases if aliases else [],
+ aliases=aliases,
help_text=help_text,
help_epilog=help_epilog,
style=style,
@@ -828,49 +1933,43 @@ class Falyx:
spinner_type=spinner_type,
spinner_style=spinner_style,
spinner_speed=spinner_speed,
- tags=tags if tags else [],
+ hooks=hooks,
+ before_hooks=before_hooks,
+ success_hooks=success_hooks,
+ error_hooks=error_hooks,
+ after_hooks=after_hooks,
+ teardown_hooks=teardown_hooks,
+ tags=tags,
logging_hooks=logging_hooks,
retry=retry,
retry_all=retry_all,
- retry_policy=retry_policy or RetryPolicy(),
- options_manager=self.options,
+ retry_policy=retry_policy,
arg_parser=arg_parser,
- arguments=arguments or [],
+ arguments=arguments,
argument_config=argument_config,
custom_parser=custom_parser,
custom_help=custom_help,
+ custom_tldr=custom_tldr,
+ custom_usage=custom_usage,
+ execution_options=execution_options,
auto_args=auto_args,
- arg_metadata=arg_metadata or {},
+ arg_metadata=arg_metadata,
simple_help_signature=simple_help_signature,
+ options_manager=self.options_manager,
ignore_in_history=ignore_in_history,
program=self.program,
)
- if hooks:
- if not isinstance(hooks, HookManager):
- raise NotAFalyxError("hooks must be an instance of HookManager.")
- command.hooks = hooks
-
- for hook in before_hooks or []:
- command.hooks.register(HookType.BEFORE, hook)
- for hook in success_hooks or []:
- command.hooks.register(HookType.ON_SUCCESS, hook)
- for hook in error_hooks or []:
- command.hooks.register(HookType.ON_ERROR, hook)
- for hook in after_hooks or []:
- command.hooks.register(HookType.AFTER, hook)
- for hook in teardown_hooks or []:
- command.hooks.register(HookType.ON_TEARDOWN, hook)
-
- if spinner:
- command.hooks.register(HookType.BEFORE, spinner_before_hook)
- command.hooks.register(HookType.ON_TEARDOWN, spinner_teardown_hook)
-
self.commands[key] = command
+ _ = self._entry_map
return command
- def get_bottom_row(self) -> list[str]:
- """Returns the bottom row of the table for displaying additional commands."""
+ def _get_bottom_row(self) -> list[str]:
+ """Build the special bottom-row entries for menu tables.
+
+ Returns:
+ list[str]: Rendered help, history, and exit command labels.
+ """
bottom_row = []
if self.help_command:
bottom_row.append(
@@ -888,26 +1987,72 @@ class Falyx:
)
return bottom_row
+ def _iter_visible_entries(
+ self,
+ *,
+ include_builtins: bool = False,
+ include_help: bool = False,
+ include_history: bool = False,
+ include_exit: bool = False,
+ ) -> list[Command | FalyxNamespace]:
+ """Collect visible entries for menu or validation message use.
+
+ Args:
+ include_builtins (bool): Whether normal builtin commands should be included.
+ include_help (bool): Whether the help command should be appended.
+ include_history (bool): Whether the history command should be appended.
+ include_exit (bool): Whether the exit command should be appended.
+
+ Returns:
+ list[Command | FalyxNamespace]: Visible entries in display order.
+ """
+ visible: list[Command | FalyxNamespace] = []
+ visible.extend([cmd for cmd in self.commands.values() if not cmd.hidden])
+ visible.extend([ns for ns in self.namespaces.values() if not ns.hidden])
+ if include_builtins:
+ visible.extend([cmd for cmd in self.builtins.values() if not cmd.hidden])
+ if include_help:
+ visible.append(self.help_command)
+ if include_history and self.history_command:
+ visible.append(self.history_command)
+ if include_exit:
+ visible.append(self.exit_command)
+ return visible
+
def build_default_table(self) -> Table:
+ """Build the standard Rich table used for menu display.
+
+ Returns:
+ Table: Default menu table for the current namespace.
"""
- Build the standard table layout. Developers can subclass or call this
- in custom tables.
- """
- table = Table(title=self.title, show_header=False, box=box.SIMPLE) # type: ignore[arg-type]
- visible_commands = [item for item in self.commands.items() if not item[1].hidden]
- for chunk in chunks(visible_commands, self.columns):
+ table = Table(
+ title=self.title,
+ show_header=False,
+ box=box.SIMPLE,
+ title_style=self.title_style,
+ caption=self.caption,
+ caption_style=self.caption_style,
+ )
+ visible = self._iter_visible_entries()
+ for chunk in chunks(visible, self.columns):
row = []
- for key, command in chunk:
- row.append(f"[{key}] [{command.style}]{command.description}")
+ for entry in chunk:
+ escaped_key = escape(f"[{entry.key}]")
+ row.append(f"{escaped_key} [{entry.style}]{entry.description}")
table.add_row(*row)
- bottom_row = self.get_bottom_row()
+ bottom_row = self._get_bottom_row()
for row in chunks(bottom_row, self.columns):
table.add_row(*row)
return table
def build_placeholder_menu(self) -> StyleAndTextTuples:
- """
- Builds a menu placeholder for show_placeholder_menu.
+ """Build placeholder text for the interactive prompt.
+
+ The placeholder summarizes visible commands and special bottom-row entries
+ and is used when `show_placeholder_menu` is enabled.
+
+ Returns:
+ StyleAndTextTuples: Prompt Toolkit-compatible formatted placeholder.
"""
visible_commands = [item for item in self.commands.items() if not item[1].hidden]
if not visible_commands:
@@ -916,14 +2061,25 @@ class Falyx:
placeholder: list[str] = []
for key, command in visible_commands:
placeholder.append(f"[{key}] [{command.style}]{command.description}[/]")
- for command_str in self.get_bottom_row():
+ for command_str in self._get_bottom_row():
placeholder.append(command_str)
return rich_text_to_prompt_text(" ".join(placeholder))
@property
def table(self) -> Table:
- """Creates or returns a custom table to display the menu commands."""
+ """Return the active menu table for this namespace.
+
+ When `custom_table` is callable, it is invoked and must return a Rich
+ `Table`. When `custom_table` is already a `Table`, that instance is reused.
+ Otherwise the default menu table is built.
+
+ Returns:
+ Table: Table used by menu rendering.
+
+ Raises:
+ FalyxError: If a custom table factory returns a non-`Table` value.
+ """
if callable(self.custom_table):
custom_table = self.custom_table(self)
if not isinstance(custom_table, Table):
@@ -936,283 +2092,643 @@ class Falyx:
else:
return self.build_default_table()
- def parse_preview_command(self, input_str: str) -> tuple[bool, str]:
- if input_str.startswith("?"):
- return True, input_str[1:].strip()
- return False, input_str.strip()
+ def resolve_entry(
+ self,
+ token: str,
+ ) -> tuple[Command | FalyxNamespace | None, list[str]]:
+ """Resolve a token to a command or namespace entry.
- async def get_command(
- self, raw_choices: str, from_validate=False, from_help=False
- ) -> tuple[bool, Command | None, tuple, dict[str, Any]]:
+ Resolution is case-insensitive and proceeds in three stages:
+
+ 1. Exact identifier match
+ 2. Unique prefix match
+ 3. Close-match suggestion lookup
+
+ Args:
+ token (str): Raw user token to resolve.
+
+ Returns:
+ tuple[Command | FalyxNamespace | None, list[str]]: Resolved entry, if
+ any, plus suggestion strings when resolution fails.
"""
- Returns the selected command based on user input.
- Supports keys, aliases, and abbreviations.
+ normalized = token.upper().strip()
+
+ # exact match
+ if normalized in self._entry_map:
+ return self._entry_map[normalized], []
+
+ # unique prefix match
+ prefix_matches = []
+ seen = set()
+ for key, entry in self._entry_map.items():
+ if key.startswith(normalized) and id(entry) not in seen:
+ prefix_matches.append(entry)
+ seen.add(id(entry))
+
+ if len(prefix_matches) == 1:
+ return prefix_matches[0], []
+
+ # close match suggestions
+ suggestions = get_close_matches(
+ normalized, list(self._entry_map.keys()), n=3, cutoff=0.7
+ )
+ return None, suggestions
+
+ async def prepare_route(
+ self,
+ raw_arguments: list[str] | str,
+ *,
+ mode: FalyxMode | None = None,
+ from_validate: bool = False,
+ ) -> tuple[RouteResult, tuple, dict[str, Any], dict[str, Any]]:
+ """Tokenize input, resolve a route, and parse leaf-command arguments.
+
+ This is the main preparation boundary between raw user input and executable
+ command dispatch. It:
+
+ - tokenizes shell-style input
+ - detects preview-prefixed commands
+ - creates an initial `InvocationContext`
+ - resolves a `RouteResult` through namespace routing
+ - delegates leaf argument parsing to the resolved command when appropriate
+
+ Args:
+ raw_arguments (list[str] | str): Raw argv-style input as a string or token list.
+ mode (FalyxMode | None): Optional mode override for the initial invocation context.
+ from_validate (bool): Whether errors should be surfaced as prompt validation
+ errors instead of normal runtime output.
+
+ Returns:
+ tuple[RouteResult, tuple, dict[str, Any], dict[str, Any]]:
+ Resolved route, positional args, keyword args, and execution args.
+
+ Raises:
+ ValidationError: If `from_validate` is `True` and tokenization or argument parsing fails.
+ CommandArgumentError: If `from_validate` is `False` and argument parsing fails
"""
- args = ()
+ args: tuple = ()
kwargs: dict[str, Any] = {}
- try:
- choice, *input_args = shlex.split(raw_choices)
- except ValueError:
- return False, None, args, kwargs
- is_preview, choice = self.parse_preview_command(choice)
- if is_preview and not choice and self.help_command:
- is_preview = False
- choice = "?"
- elif is_preview and not choice:
- # No help (list) command enabled
- if not from_validate:
- self.console.print(
- f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode."
- )
- return is_preview, None, args, kwargs
-
- choice = choice.upper()
- name_map = self._name_map
- run_command = None
- if name_map.get(choice):
- run_command = name_map[choice]
- else:
- prefix_matches = [
- cmd for key, cmd in name_map.items() if key.startswith(choice)
- ]
- if len(prefix_matches) == 1:
- run_command = prefix_matches[0]
-
- if run_command:
- if not from_validate:
- logger.info("Command '%s' selected.", run_command.key)
- if is_preview:
- return True, run_command, args, kwargs
- elif self.is_cli_mode or from_help:
- return False, run_command, args, kwargs
+ execution_args: dict[str, Any] = {}
+ if isinstance(raw_arguments, str):
try:
- args, kwargs = await run_command.parse_args(input_args, from_validate)
- except (CommandArgumentError, Exception) as error:
- if not from_validate:
- run_command.render_help()
- self.console.print(
- f"[{OneColors.DARK_RED}]❌ [{run_command.key}]: {error}"
- )
- else:
+ tokens = shlex.split(raw_arguments)
+ except ValueError as error:
+ if from_validate:
raise ValidationError(
- message=str(error), cursor_position=len(raw_choices)
- )
- return is_preview, None, args, kwargs
- except HelpSignal:
- return True, None, args, kwargs
- return is_preview, run_command, args, kwargs
-
- fuzzy_matches = get_close_matches(choice, list(name_map.keys()), n=3, cutoff=0.7)
- if fuzzy_matches:
- if not from_validate:
- self.console.print(
- f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'. "
- "Did you mean:"
- )
- for match in fuzzy_matches:
- cmd = name_map[match]
- self.console.print(f" • [bold]{match}[/] → {cmd.description}")
- else:
- raise ValidationError(
- message=f"Unknown command '{choice}'. Did you mean: "
- f"{', '.join(fuzzy_matches)}?",
- cursor_position=len(raw_choices),
- )
+ cursor_position=len(raw_arguments), message=f"{error}"
+ ) from error
+ raise UsageError(str(error)) from error
+ elif isinstance(raw_arguments, list):
+ tokens = raw_arguments
else:
- if not from_validate:
- self.console.print(
- f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'[/]"
- )
- else:
+ if from_validate:
+ assert (
+ False
+ ), "Validator can only pass a string or list of strings as raw_arguments."
+ raise UsageError(
+ "raw_arguments must be a string or list of strings."
+ ) from TypeError("invalid type for raw_arguments")
+
+ is_preview = False
+ if tokens and tokens[0].startswith("?") and len(tokens[0]) > 1:
+ is_preview = True
+ tokens[0] = tokens[0][1:]
+
+ context = InvocationContext(
+ program=self.program,
+ program_style=self.program_style,
+ typed_path=[],
+ mode=mode or self.options_manager.get("mode"),
+ is_preview=is_preview,
+ )
+
+ try:
+ route = await self.resolve_route(
+ tokens,
+ invocation_context=context,
+ is_preview=is_preview,
+ )
+ except FalyxError as error:
+ if from_validate:
+ hint = f" hint: {error.hint}" if error.hint else ""
raise ValidationError(
- message=f"Unknown command '{choice}'.",
- cursor_position=len(raw_choices),
+ cursor_position=len(raw_arguments), message=f"{error}{hint}"
+ ) from error
+ raise
+
+ if route.is_preview:
+ return route, args, kwargs, execution_args
+
+ if route.kind is RouteKind.COMMAND:
+ assert route.command is not None
+ try:
+ args, kwargs, execution_args = await route.command.resolve_args(
+ route.leaf_argv,
+ from_validate=from_validate,
+ invocation_context=route.context,
)
- return is_preview, None, args, kwargs
+ except CommandArgumentError as error:
+ if from_validate:
+ hint = f" hint: {error.hint}" if error.hint else ""
+ raise ValidationError(
+ cursor_position=len(raw_arguments), message=f"{error}{hint}"
+ ) from error
+ else:
+ raise error
+ except HelpSignal:
+ if not from_validate:
+ raise
+ return route, args, kwargs, execution_args
- def _create_context(
- self, selected_command: Command, args: tuple, kwargs: dict[str, Any]
- ) -> ExecutionContext:
- """Creates an ExecutionContext object for the selected command."""
- return ExecutionContext(
- name=selected_command.description,
- args=args,
- kwargs=kwargs,
- action=selected_command,
- )
+ return route, args, kwargs, execution_args
- async def _handle_action_error(
- self, selected_command: Command, error: Exception
+ async def _render_unknown_route(
+ self,
+ route: RouteResult,
) -> None:
- """Handles errors that occur during the action of the selected command."""
- 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}"
+ """Render help plus suggestions for an unresolved route.
+
+ Args:
+ route (RouteResult): Unknown route returned by namespace resolution.
+
+ Raises:
+ FalyxError: If the route is a preview route, which cannot be rendered.
+ EntryNotFoundError: If the route is unknown and cannot be resolved.
+ """
+ if route.kind is RouteKind.NAMESPACE_MENU:
+ raise FalyxError("preview mode is only supported for commands.")
+ else:
+ raise EntryNotFoundError(
+ unknown_name=route.current_head,
+ suggestions=route.suggestions,
+ )
+
+ async def _dispatch_route(
+ self,
+ route: RouteResult,
+ *,
+ args: tuple = (),
+ kwargs: dict[str, Any] | None = None,
+ execution_args: dict[str, Any] | None = None,
+ raise_on_error: bool = False,
+ wrap_errors: bool = True,
+ summary_last_result: bool = False,
+ ) -> Any | None:
+ """Dispatch a prepared route to help rendering, menu flow, or execution.
+
+ This method is the final route-handling stage after preparation. It knows
+ how to handle namespace menus, namespace help, namespace TLDR, unknown
+ routes, preview routes, and normal leaf-command execution.
+
+ Args:
+ route (RouteResult): Prepared route to dispatch.
+ args (tuple): Positional arguments prepared for a leaf command.
+ kwargs (dict[str, Any] | None): Keyword arguments prepared for a leaf command.
+ execution_args (dict[str, Any] | None): Execution-only arguments such as
+ confirmation or retry overrides.
+ raise_on_error (bool): Whether executor errors should be re-raised.
+ wrap_errors (bool): Whether executor errors should be wrapped as `FalyxError`.
+ summary_last_result (bool): Whether summary output should only have the last
+ result when supported.
+
+ Returns:
+ Any | None: Command result for executed leaf commands, otherwise `None`.
+
+ Raises:
+ FalyxError: If the route is invalid for preview or if execution fails and
+ `wrap_errors` is `True`.
+ Exception: If execution fails and `raise_on_error` is `True` and
+ `wrap_errors` is `False`.
+ EntryNotFoundError: If the route is unknown and cannot be resolved.
+ KeyboardInterrupt: If execution is interrupted by the user and `wrap_errors`
+ is `False`.
+ EOFError: If execution receives an unexpected end of input and `wrap_errors`
+ is `False`.
+ """
+ route.namespace._apply_root_options()
+ if route.is_preview:
+ if route.kind is RouteKind.COMMAND and route.command:
+ logger.info("preview command '%s' selected.", route.command.key)
+ await route.command.preview()
+ else:
+ logger.info("preview route selected with no command.")
+ await self._render_unknown_route(route)
+ return None
+
+ if route.kind is RouteKind.NAMESPACE_MENU:
+ await route.namespace.menu()
+ return None
+
+ if route.kind is RouteKind.NAMESPACE_HELP:
+ await route.namespace.render_namespace_help(route.context)
+ return None
+
+ if route.kind is RouteKind.NAMESPACE_TLDR:
+ await route.namespace.render_namespace_help(route.context, tldr=True)
+ return None
+
+ if route.kind is RouteKind.UNKNOWN:
+ await self._render_unknown_route(route)
+ return None
+
+ if route.kind is RouteKind.COMMAND:
+ if not route.command:
+ raise FalyxError("invalid route: command expected but not found.")
+
+ command = route.command
+
+ if command is route.namespace.help_command:
+ kwargs = kwargs or {}
+ # pop the help command key from the typed path to avoid it being
+ # treated as a real argument during help rendering
+ route.context.typed_path.pop()
+ route.context.segments.pop()
+ kwargs["invocation_context"] = route.context
+
+ logger.debug(
+ "Executing command '%s' with args=%s, kwargs=%s, execution_args=%s",
+ route.command.description,
+ args,
+ kwargs,
+ execution_args,
+ )
+ route.namespace.options_manager.seed_missing(
+ route.namespace_defaults,
+ )
+ with route.namespace.options_manager.override_namespace(
+ route.namespace_overrides,
+ "default",
+ ):
+ return await self._executor.execute(
+ command=route.command,
+ args=args,
+ kwargs=kwargs or {},
+ execution_args=execution_args or {},
+ raise_on_error=raise_on_error,
+ wrap_errors=wrap_errors,
+ summary_last_result=summary_last_result,
+ )
+
+ async def execute_command(
+ self,
+ raw_arguments: list[str] | str,
+ *,
+ raise_on_error: bool = False,
+ wrap_errors: bool = True,
+ summary_last_result: bool = False,
+ mode: FalyxMode = FalyxMode.MENU,
+ ) -> Any | None:
+ """Execute a command from a raw CLI-style input string.
+
+ This method resolves the requested command from `raw_arguments`, parses any
+ command-specific arguments, handles preview and exit behavior, and delegates
+ actual execution to the shared `CommandExecutor`.
+
+ Behavior:
+ - Resolves the command and its parsed `args`, `kwargs`, and
+ `execution_args` via `prepare_route()`.
+ - Returns `None` when help output is triggered, argument parsing fails,
+ the command cannot be found, or preview mode is requested.
+ - For normal execution, forwards the resolved command and execution
+ options to `_executor.execute()`.
+
+ Args:
+ raw_arguments (str): Raw command input string, including the command name
+ and any CLI-style arguments (for example, ``"deploy --region us-east"``).
+ raise_on_error (bool): Whether execution errors raised by the underlying
+ executor should be re-raised to the caller.
+ wrap_errors (bool): Whether execution errors should be wrapped in a
+ `FalyxError` by the underlying executor before being raised.
+ summary_last_result (bool): Whether summary output should include the last
+ result when execution summary reporting is requested.
+ mode (FalyxMode): Runtime mode used while preparing the route.
+
+ Returns:
+ Any | None: The command result returned by the underlying executor, or
+ `None` if execution does not occur because help was shown, preview mode
+ was used, parsing failed, or the command was not found.
+
+ Raises:
+ QuitSignal: If the resolved command is the configured exit command.
+ FalyxError: If the route is invalid for preview or if execution fails and
+ `wrap_errors` is `True`.
+ Exception: If execution fails and `raise_on_error` is `True` and
+ `wrap_errors` is `False`.
+ KeyboardInterrupt: If execution is interrupted by the user and
+ `wrap_errors` is `False`.
+ EOFError: If execution receives an unexpected end of input and
+ `wrap_errors` is `False`.
+
+ Notes:
+ - This method is the primary programmatic entrypoint for executing a
+ command from a raw input string outside the interactive menu loop.
+ - One of the flags `raise_on_error` or `wrap_errors` must be `True` to
+ ensure that errors are properly handled.
+ """
+ if not (raise_on_error or wrap_errors):
+ raise FalyxError(
+ "Falyx.execute_command() requires either raise_on_error=True "
+ "or wrap_errors=True."
+ )
+ route, args, kwargs, execution_args = await self.prepare_route(
+ raw_arguments, mode=mode
)
- async def process_command(self) -> bool:
- """Processes the action of the selected command."""
+ assert route is not None, "prepare_route should never return None."
+
+ route.namespace.options_manager.seed_missing(
+ route.root_defaults,
+ namespace_name="root",
+ )
+ with route.namespace.options_manager.override_namespace(
+ route.root_overrides,
+ namespace_name="root",
+ ):
+ return await self._dispatch_route(
+ route=route,
+ args=args,
+ kwargs=kwargs,
+ execution_args=execution_args,
+ raise_on_error=raise_on_error,
+ wrap_errors=wrap_errors,
+ summary_last_result=summary_last_result,
+ )
+
+ def resolve_completion_route(
+ self,
+ committed_tokens: list[str],
+ *,
+ stub: str,
+ cursor_at_end_of_token: bool,
+ invocation_context: InvocationContext,
+ is_preview: bool = False,
+ ) -> CompletionRoute:
+ """Resolve partial input for autocompletion.
+
+ Unlike full routing, completion routing tolerates incomplete trailing input.
+ It stops at the first point where completion must either suggest namespace
+ entries or delegate the remaining input to a leaf command's argument parser.
+
+ Args:
+ committed_tokens (list[str]): Tokens fully committed before the active stub.
+ stub (str): Current token fragment under the cursor.
+ cursor_at_end_of_token (bool): Whether the cursor sits at a token boundary.
+ invocation_context (InvocationContext): Current routed invocation context.
+ is_preview (bool): Whether the input is preview-prefixed.
+
+ Returns:
+ CompletionRoute: Partial route used by the completer.
+ """
+ namespace = self
+ route_context = invocation_context
+ remaining_in_namespace = [stub]
+
+ remaining = list(committed_tokens)
+ while remaining:
+ remaining = list(remaining)
+ remaining_in_namespace = list(remaining) + ([stub] if stub else [])
+ try:
+ parse_result = namespace.parser.parse_args(remaining)
+ except FalyxOptionError:
+ # If committed tokens end with a namespace-level option, the completer should
+ # suggest values for that option instead of namespace entries.
+ return CompletionRoute(
+ namespace=namespace,
+ context=route_context,
+ command=None,
+ remaining_argv=remaining_in_namespace,
+ stub=stub,
+ cursor_at_end_of_token=cursor_at_end_of_token,
+ expecting_entry=True,
+ is_preview=is_preview,
+ )
+
+ remaining = list(parse_result.remaining_argv)
+ remaining_in_namespace = list(remaining) + ([stub] if stub else [])
+
+ if not remaining:
+ break
+
+ head = remaining.pop(0)
+ entry, _ = namespace.resolve_entry(head)
+
+ if entry is None:
+ if remaining or stub:
+ return CompletionRoute(
+ namespace=namespace,
+ context=route_context,
+ command=None,
+ remaining_argv=remaining_in_namespace,
+ stub="",
+ cursor_at_end_of_token=cursor_at_end_of_token,
+ expecting_entry=False,
+ is_preview=is_preview,
+ )
+ # Still routing namespace entries; could not resolve this token.
+ # Let the completer suggest entries or namespace-level flags.
+ return CompletionRoute(
+ namespace=namespace,
+ context=route_context,
+ command=None,
+ remaining_argv=remaining_in_namespace,
+ stub=head,
+ cursor_at_end_of_token=cursor_at_end_of_token,
+ expecting_entry=True,
+ is_preview=is_preview,
+ )
+
+ route_context = route_context.with_path_segment(head, style=entry.style)
+
+ if isinstance(entry, FalyxNamespace):
+ namespace = entry.namespace
+ continue
+
+ # Leaf command found: everything after this belongs to CAP unchanged.
+ return CompletionRoute(
+ namespace=namespace,
+ context=route_context,
+ command=entry,
+ leaf_argv=remaining,
+ stub=stub,
+ cursor_at_end_of_token=cursor_at_end_of_token,
+ expecting_entry=False,
+ is_preview=is_preview,
+ )
+
+ # No committed leaf yet: next token should be a namespace entry.
+ return CompletionRoute(
+ namespace=namespace,
+ context=route_context,
+ command=None,
+ leaf_argv=[],
+ remaining_argv=remaining_in_namespace,
+ stub=stub,
+ cursor_at_end_of_token=cursor_at_end_of_token,
+ expecting_entry=True,
+ is_preview=is_preview,
+ )
+
+ async def resolve_route(
+ self,
+ tokens: list[str],
+ *,
+ invocation_context: InvocationContext,
+ is_preview: bool = False,
+ root_defaults: dict[str, Any] | None = None,
+ root_overrides: dict[str, Any] | None = None,
+ ) -> RouteResult:
+ """Resolve an invocation path across namespaces until a leaf boundary.
+
+ Routing is recursive and namespace-aware. It stops when one of the
+ following occurs:
+
+ - no tokens remain, targeting the current namespace menu
+ - a namespace-level help or TLDR flag is encountered
+ - an unknown token is found
+ - a leaf command is reached
+
+ Args:
+ tokens (list[str]): Remaining tokens to route.
+ invocation_context (InvocationContext): Routed context accumulated so far.
+ is_preview (bool): Whether the input is preview-prefixed.
+ Returns:
+ RouteResult: Final routed result for the supplied token path.
+ """
+ # 1. Namespace-level parsing for help/tldr flags and root/session options
+ parse_result = self.parser.parse_args(tokens)
+ if not root_defaults:
+ root_defaults = {}
+ if not root_overrides:
+ root_overrides = {}
+ parse_result.root_defaults = root_defaults | parse_result.root_defaults
+ parse_result.root_options = root_overrides | parse_result.root_options
+
+ tokens = parse_result.remaining_argv
+
+ # 2. Help or TLDR requested for this namespace
+ if parse_result.help:
+ return RouteResult(
+ kind=RouteKind.NAMESPACE_HELP,
+ namespace=self,
+ context=invocation_context,
+ current_head=parse_result.current_head,
+ is_preview=is_preview,
+ )
+ if parse_result.tldr:
+ return RouteResult(
+ kind=RouteKind.NAMESPACE_TLDR,
+ namespace=self,
+ context=invocation_context,
+ current_head=parse_result.current_head,
+ is_preview=is_preview,
+ )
+
+ # 3. No more tokens -> this namespace itself was targeted
+ if not tokens and (parse_result.namespace_options or parse_result.root_options):
+ return RouteResult(
+ kind=RouteKind.UNKNOWN,
+ namespace=self,
+ context=invocation_context,
+ current_head=parse_result.current_head,
+ is_preview=is_preview,
+ )
+ elif not tokens:
+ return RouteResult(
+ kind=RouteKind.NAMESPACE_MENU,
+ namespace=self,
+ context=invocation_context,
+ is_preview=is_preview,
+ root_defaults=parse_result.root_defaults,
+ root_overrides=parse_result.root_options,
+ )
+
+ head, *tail = tokens
+
+ # 4. Resolve the next entry in this namespace
+ entry, suggestions = self.resolve_entry(head)
+ if entry is None:
+ return RouteResult(
+ kind=RouteKind.UNKNOWN,
+ namespace=self,
+ context=invocation_context,
+ current_head=head,
+ suggestions=suggestions,
+ is_preview=is_preview,
+ )
+
+ route_context = invocation_context.with_path_segment(head, style=entry.style)
+
+ # 5. Namespace entry -> recurse with remaining tokens
+ if isinstance(entry, FalyxNamespace):
+ return await entry.namespace.resolve_route(
+ tail,
+ invocation_context=route_context,
+ is_preview=is_preview,
+ root_defaults=parse_result.root_defaults,
+ root_overrides=parse_result.root_options,
+ )
+
+ # 6. Leaf command -> stop routing; leave tail untouched for leaf parser
+ return RouteResult(
+ kind=RouteKind.COMMAND,
+ namespace=self,
+ context=route_context,
+ command=entry,
+ leaf_argv=tail,
+ current_head=head,
+ is_preview=is_preview,
+ root_defaults=parse_result.root_defaults,
+ root_overrides=parse_result.root_options,
+ namespace_defaults=parse_result.namespace_defaults,
+ namespace_overrides=parse_result.namespace_options,
+ )
+
+ async def _process_command(self) -> None:
+ """Read one prompt input from the interactive session and execute it.
+
+ This helper refreshes the Prompt Toolkit app, collects raw input from the
+ cached prompt session, and forwards that input to `execute_command()`.
+ """
app = get_app()
await asyncio.sleep(0.1)
app.invalidate()
with patch_stdout(raw=True):
- choice = await self.prompt_session.prompt_async()
- is_preview, selected_command, args, kwargs = await self.get_command(choice)
- if not selected_command:
- logger.info("Invalid command '%s'.", choice)
- return True
-
- if is_preview:
- logger.info("Preview command '%s' selected.", selected_command.key)
- await selected_command.preview()
- return True
-
- self.last_run_command = selected_command
-
- if selected_command == self.exit_command:
- logger.info("Back selected: exiting %s", self.get_title())
- return False
-
- context = self._create_context(selected_command, args, kwargs)
- context.start_timer()
+ raw_arguments = await self.prompt_session.prompt_async()
try:
- await self.hooks.trigger(HookType.BEFORE, context)
- result = await selected_command(*args, **kwargs)
- context.result = result
- await self.hooks.trigger(HookType.ON_SUCCESS, context)
- except Exception as error:
- context.exception = error
- await self.hooks.trigger(HookType.ON_ERROR, context)
- await self._handle_action_error(selected_command, error)
- finally:
- context.stop_timer()
- await self.hooks.trigger(HookType.AFTER, context)
- await self.hooks.trigger(HookType.ON_TEARDOWN, context)
- return True
-
- async def run_key(
- self,
- command_key: str,
- return_context: bool = False,
- args: tuple = (),
- kwargs: dict[str, Any] | None = None,
- ) -> Any:
- """Run a command by key without displaying the menu (non-interactive mode)."""
- self.debug_hooks()
- is_preview, selected_command, _, __ = await self.get_command(command_key)
- kwargs = kwargs or {}
-
- self.last_run_command = selected_command
-
- if not selected_command:
- return None
-
- if is_preview:
- logger.info("Preview command '%s' selected.", selected_command.key)
- await selected_command.preview()
- return None
-
- logger.info(
- "[run_key] Executing: %s — %s",
- selected_command.key,
- selected_command.description,
- )
-
- context = self._create_context(selected_command, args, kwargs)
- context.start_timer()
- try:
- await self.hooks.trigger(HookType.BEFORE, context)
- result = await selected_command(*args, **kwargs)
- context.result = result
-
- await self.hooks.trigger(HookType.ON_SUCCESS, context)
- logger.info("[run_key] '%s' complete.", selected_command.description)
- except (KeyboardInterrupt, EOFError) as error:
- logger.warning(
- "[run_key] Interrupted by user: %s", selected_command.description
- )
- raise FalyxError(
- f"[run_key] ⚠️ '{selected_command.description}' interrupted by user."
- ) from error
- except Exception as error:
- context.exception = error
- await self.hooks.trigger(HookType.ON_ERROR, context)
- await self._handle_action_error(selected_command, error)
- raise FalyxError(
- f"[run_key] ❌ '{selected_command.description}' failed."
- ) from error
- finally:
- context.stop_timer()
- await self.hooks.trigger(HookType.AFTER, context)
- await self.hooks.trigger(HookType.ON_TEARDOWN, context)
-
- return context if return_context else context.result
-
- def _set_retry_policy(self, selected_command: Command) -> None:
- """Sets the retry policy for the command based on CLI arguments."""
- assert isinstance(self.cli_args, Namespace), "CLI arguments must be provided."
- if (
- self.cli_args.retries
- or self.cli_args.retry_delay
- or self.cli_args.retry_backoff
- ):
- selected_command.retry_policy.enabled = True
- if self.cli_args.retries:
- selected_command.retry_policy.max_retries = self.cli_args.retries
- if self.cli_args.retry_delay:
- selected_command.retry_policy.delay = self.cli_args.retry_delay
- if self.cli_args.retry_backoff:
- selected_command.retry_policy.backoff = self.cli_args.retry_backoff
- if isinstance(selected_command.action, Action):
- selected_command.action.set_retry_policy(selected_command.retry_policy)
- else:
- logger.warning(
- "[Command:%s] Retry requested, but action is not an Action instance.",
- selected_command.key,
- )
-
- def print_message(self, message: str | Markdown | dict[str, Any]) -> None:
- """Prints a message to the console."""
- if isinstance(message, (str, Markdown)):
- self.console.print(message)
- elif isinstance(message, dict):
- self.console.print(
- *message.get("args", tuple()),
- **message.get("kwargs", {}),
- )
- else:
- raise TypeError(
- "Message must be a string, Markdown, or dictionary with args and kwargs."
+ await self.execute_command(
+ raw_arguments,
+ raise_on_error=False,
+ wrap_errors=True,
+ summary_last_result=True,
)
+ except FalyxError as error:
+ print_error(message=error)
async def menu(self) -> None:
- """Runs the menu and handles user input."""
- logger.info("Starting menu: %s", self.get_title())
- self.options.set("mode", FalyxMode.MENU)
- self.debug_hooks()
+ """Run the interactive menu loop for this namespace.
+
+ The menu loop renders the current table view, reads commands from the prompt
+ session, handles navigation and cancellation signals, and prints optional
+ welcome and exit messages.
+ """
+ logger.info("Starting menu: %s", self.title)
+ self.options_manager.set("mode", FalyxMode.MENU)
if self.welcome_message:
- self.print_message(self.welcome_message)
+ self.console.print(self.welcome_message)
try:
while True:
- if not self.options.get("hide_menu_table", self._hide_menu_table):
+ if not self.options_manager.get("hide_menu_table", self._hide_menu_table):
if callable(self.render_menu):
self.render_menu(self)
else:
self.console.print(self.table, justify="center")
try:
- should_continue = await self.process_command()
- if not should_continue:
- break
+ await self._process_command()
except (EOFError, KeyboardInterrupt):
logger.info("EOF or KeyboardInterrupt. Exiting menu.")
break
+ except HelpSignal:
+ logger.info("[HelpSignal]. <- Returning to the menu.")
except QuitSignal:
logger.info("[QuitSignal]. <- Exiting menu.")
break
@@ -1220,231 +2736,134 @@ class Falyx:
logger.info("[BackSignal]. <- Returning to the menu.")
except CancelSignal:
logger.info("[CancelSignal]. <- Returning to the menu.")
+ except asyncio.CancelledError:
+ logger.info("[asyncio.CancelledError]. <- Returning to the menu.")
finally:
- logger.info("Exiting menu: %s", self.get_title())
+ logger.info("Exiting menu: %s", self.title)
if self.exit_message:
- self.print_message(self.exit_message)
+ self.console.print(self.exit_message)
- async def run(
- self,
- falyx_parsers: FalyxParsers | None = None,
- root_parser: ArgumentParser | None = None,
- subparsers: _SubParsersAction | None = None,
- callback: Callable[..., Any] | None = None,
- always_start_menu: bool = False,
- ) -> None:
+ def _apply_root_options(self) -> None:
+ """Apply parsed root/session options to runtime state.
+
+ This updates logging verbosity and debug-hook registration.
"""
- Entrypoint for executing a Falyx CLI application via structured subcommands.
+ falyx_logger = logging.getLogger("falyx")
+ if self.options_manager.get("verbose", False, "root"):
+ falyx_logger.setLevel(logging.DEBUG)
+ else:
+ falyx_logger.setLevel(logging.WARNING)
- This method parses CLI arguments, configures the runtime environment, and dispatches
- execution to the appropriate command mode:
+ if self.options_manager.get("debug_hooks", False, "root"):
+ self.register_all_with_debug_hooks()
+ logger.debug("Enabling global debug hooks for all commands")
- - help - Show help output, optionally filtered by tag.
- - version - Print the program version and exit.
- - preview - Display a preview of the specified command without executing it.
- - run - Execute a single command with parsed arguments and lifecycle hooks.
- - run-all - Run all commands matching a tag concurrently (with default args).
- - (default) - Launch the interactive Falyx menu loop.
+ async def run(self, always_start_menu: bool = False) -> None:
+ """Execute the Falyx application using CLI-driven dispatch.
- It also applies CLI flags such as `--verbose`, `--debug-hooks`, and summary reporting,
- and supports an optional callback for post-parse setup.
+ This method is the primary entrypoint for Falyx applications.
+
+ - parses root CLI flags using `FalyxParser`
+ - optionally invokes a post-parse callback
+ - applies session/runtime options
+ - renders help immediately when requested
+ - prepares and dispatches the routed command
+ - exits with CLI-appropriate status codes
+ - optionally falls through to interactive menu mode
Args:
- falyx_parsers (FalyxParsers | None):
- Preconfigured argument parser set. If not provided, a default parser
- is created using the registered commands and passed-in `root_parser`
- or `subparsers`.
- root_parser (ArgumentParser | None):
- Optional root parser to merge into the CLI (used if `falyx_parsers`
- is not supplied).
- subparsers (_SubParsersAction | None):
- Optional subparser group to extend (used if `falyx_parsers` is not supplied).
- callback (Callable[..., Any] | None):
- An optional function or coroutine to run after parsing CLI arguments,
- typically for initializing logging, environment setup, or other
- pre-execution configuration.
+ always_start_menu (bool): Whether to enter menu mode after a successful
+ command dispatch when the route itself does not already target help
+ or a namespace menu.
Raises:
FalyxError:
- If invalid parser objects are supplied, or CLI arguments conflict
- with the expected run mode.
+ If command execution fails.
SystemExit:
- Exits with an appropriate exit code based on the selected command
- or signal (e.g. Ctrl+C triggers exit code 130).
+ Terminates the process with an appropriate exit code based on mode.
Notes:
- - `run-all` executes all tagged commands **in parallel** and does not
- supply arguments to individual commands; use `ChainedAction` or explicit
- CLI calls for ordered or parameterized workflows.
- - Most CLI commands exit the process via `sys.exit()` after completion.
- - For interactive sessions, this method falls back to `menu()`.
+ - Most CLI execution paths terminate via `sys.exit()`
+ - Interactive mode continues via `menu()`
+ - Execution options are applied in a scoped "execution" namespace
Example:
```
+ >>> import asyncio
>>> flx = Falyx()
- >>> await flx.run() # Parses CLI args and dispatches appropriately
+ >>> asyncio.run(flx.run())
```
"""
-
- if self.cli_args:
- raise FalyxError(
- "Run is incompatible with CLI arguments. Use 'run_key' instead."
- )
- if falyx_parsers:
- if not isinstance(falyx_parsers, FalyxParsers):
- raise FalyxError("falyx_parsers must be an instance of FalyxParsers.")
- else:
- falyx_parsers = get_arg_parsers(
- self.program,
- self.usage,
- self.description,
- self.epilog,
- commands=self.commands,
- root_parser=root_parser,
- subparsers=subparsers,
- )
- self.cli_args = falyx_parsers.parse_args()
- self.options.from_namespace(self.cli_args, "cli_args")
-
- if callback:
- if not callable(callback):
- raise FalyxError("Callback must be a callable function.")
- async_callback = ensure_async(callback)
- await async_callback(self.cli_args)
-
- if not self.options.get("never_prompt"):
- self.options.set("never_prompt", self._never_prompt)
-
- if not self.options.get("force_confirm"):
- self.options.set("force_confirm", self._force_confirm)
-
- if not self.options.get("hide_menu_table"):
- self.options.set("hide_menu_table", self._hide_menu_table)
-
- if self.cli_args.verbose:
- logging.getLogger("falyx").setLevel(logging.DEBUG)
-
- if self.cli_args.debug_hooks:
- logger.debug("Enabling global debug hooks for all commands")
- self.register_all_with_debug_hooks()
-
- if self.cli_args.command == "help":
- self.options.set("mode", FalyxMode.HELP)
- await self._render_help(
- tag=self.cli_args.tag, key=self.cli_args.key, tldr=self.cli_args.tldr
- )
+ if not sys.argv[1:] and not self.default_to_menu and not always_start_menu:
+ await self.render_help()
sys.exit(0)
- if self.cli_args.command == "version" or self.cli_args.version:
- self.console.print(f"[{self.version_style}]{self.program} v{self.version}[/]")
+ try:
+ route, args, kwargs, execution_args = await self.prepare_route(
+ raw_arguments=sys.argv[1:],
+ )
+ except UsageError as error:
+ if error.show_short_usage:
+ self.render_usage()
+ print_error(message=error)
+ sys.exit(2)
+ except HelpSignal:
sys.exit(0)
- if self.cli_args.command == "preview":
- self.options.set("mode", FalyxMode.PREVIEW)
- _, command, args, kwargs = await self.get_command(self.cli_args.name)
- if not command:
- self.console.print(
- f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found."
- )
- sys.exit(1)
- self.console.print(
- f"Preview of command '{command.key}': {command.description}"
+ assert route is not None, "prepare_route should never return None."
+
+ try:
+ route.namespace.options_manager.seed_missing(
+ route.root_defaults,
+ namespace_name="root",
)
- await command.preview()
+ with route.namespace.options_manager.override_namespace(
+ route.root_overrides,
+ namespace_name="root",
+ ):
+ await self._dispatch_route(
+ route=route,
+ args=args,
+ kwargs=kwargs,
+ execution_args=execution_args,
+ raise_on_error=False,
+ wrap_errors=True,
+ )
+ except EntryNotFoundError as error:
+ await self.render_help()
+ print_error(message=error)
+ sys.exit(2)
+ except (FalyxError, Exception) as error:
+ print_error(message=error)
+ if self.options_manager.get("verbose", False, "root"):
+ logger.error("Error: %s", error, exc_info=True)
+ sys.exit(1)
+ except QuitSignal:
+ logger.info("[QuitSignal]. <- Exiting run.")
+ sys.exit(130)
+ except BackSignal:
+ logger.info("[BackSignal]. <- Exiting run.")
+ sys.exit(1)
+ except CancelSignal:
+ logger.info("[CancelSignal]. <- Exiting run.")
+ sys.exit(1)
+ except FlowSignal:
+ logger.info("[FlowSignal]. <- Exiting run.")
+ sys.exit(1)
+ except asyncio.CancelledError:
+ logger.info("[asyncio.CancelledError]. <- Exiting run.")
+ sys.exit(1)
+
+ if (
+ route.kind
+ in (
+ RouteKind.NAMESPACE_MENU,
+ RouteKind.NAMESPACE_TLDR,
+ RouteKind.NAMESPACE_HELP,
+ )
+ or route.command is self.help_command
+ or not always_start_menu
+ ):
sys.exit(0)
- if self.cli_args.command == "run":
- self.options.set("mode", FalyxMode.RUN)
- is_preview, command, _, __ = await self.get_command(self.cli_args.name)
- if is_preview:
- if command is None:
- sys.exit(1)
- logger.info("Preview command '%s' selected.", command.key)
- await command.preview()
- sys.exit(0)
- if not command:
- sys.exit(1)
- self._set_retry_policy(command)
- try:
- args, kwargs = await command.parse_args(self.cli_args.command_args)
- except HelpSignal:
- sys.exit(0)
- except CommandArgumentError as error:
- self.console.print(f"[{OneColors.DARK_RED}]❌ ['{command.key}'] {error}")
- command.render_help()
- sys.exit(1)
- try:
- await self.run_key(self.cli_args.name, args=args, kwargs=kwargs)
- except FalyxError as error:
- self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]")
- sys.exit(1)
- except QuitSignal:
- logger.info("[QuitSignal]. <- Exiting run.")
- sys.exit(130)
- except BackSignal:
- logger.info("[BackSignal]. <- Exiting run.")
- sys.exit(1)
- except CancelSignal:
- logger.info("[CancelSignal]. <- Exiting run.")
- sys.exit(1)
-
- if self.cli_args.summary:
- er.summary()
- if not always_start_menu:
- sys.exit(0)
-
- if self.cli_args.command == "run-all":
- self.options.set("mode", FalyxMode.RUN_ALL)
- matching = [
- cmd
- for cmd in self.commands.values()
- if self.cli_args.tag.lower() in (tag.lower() for tag in cmd.tags)
- ]
- if not matching:
- self.console.print(
- f"[{OneColors.LIGHT_YELLOW}]⚠️ No commands found with tag: "
- f"'{self.cli_args.tag}'"
- )
- sys.exit(1)
-
- self.console.print(
- f"[{OneColors.CYAN_b}]🚀 Running all commands with tag:[/] "
- f"{self.cli_args.tag}"
- )
-
- tasks = []
- try:
- for cmd in matching:
- self._set_retry_policy(cmd)
- tasks.append(self.run_key(cmd.key))
- except Exception as error:
- self.console.print(
- f"[{OneColors.DARK_RED}]❌ Unexpected error: {error}[/]"
- )
- sys.exit(1)
-
- had_errors = False
- results = await asyncio.gather(*tasks, return_exceptions=True)
- for result in results:
- if isinstance(result, QuitSignal):
- logger.info("[QuitSignal]. <- Exiting run.")
- sys.exit(130)
- elif isinstance(result, CancelSignal):
- logger.info("[CancelSignal]. <- Execution cancelled.")
- sys.exit(1)
- elif isinstance(result, BackSignal):
- logger.info("[BackSignal]. <- Back signal received.")
- sys.exit(1)
- elif isinstance(result, FalyxError):
- self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {result}[/]")
- had_errors = True
-
- if had_errors:
- sys.exit(1)
-
- if self.cli_args.summary:
- er.summary()
- if not always_start_menu:
- sys.exit(0)
-
await self.menu()
diff --git a/falyx/hook_manager.py b/falyx/hook_manager.py
index ca0a506..ec076c2 100644
--- a/falyx/hook_manager.py
+++ b/falyx/hook_manager.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines the `HookManager` and `HookType` used in the Falyx CLI framework to manage
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Defines the `HookManager` and `HookType` used in the Falyx CLI framework to manage
execution lifecycle hooks around actions and commands.
The hook system enables structured callbacks for important stages in a Falyx action's
@@ -31,8 +30,7 @@ Hook = Union[
class HookType(Enum):
- """
- Enum for supported hook lifecycle phases in Falyx.
+ """Enum for supported hook lifecycle phases in Falyx.
HookType is used to classify lifecycle events that can be intercepted
with user-defined callbacks.
@@ -91,8 +89,7 @@ class HookType(Enum):
class HookManager:
- """
- Manages lifecycle hooks for a command or action.
+ """Manages lifecycle hooks for a command or action.
`HookManager` tracks user-defined callbacks to be run at key points in a command's
lifecycle: before execution, on success, on error, after completion, and during
@@ -114,8 +111,7 @@ class HookManager:
}
def register(self, hook_type: HookType | str, hook: Hook):
- """
- Register a new hook for a given lifecycle phase.
+ """Register a new hook for a given lifecycle phase.
Args:
hook_type (HookType | str): The hook category (e.g. "before", "on_success").
@@ -128,8 +124,7 @@ class HookManager:
self._hooks[hook_type].append(hook)
def clear(self, hook_type: HookType | None = None):
- """
- Clear registered hooks for one or all hook types.
+ """Clear registered hooks for one or all hook types.
Args:
hook_type (HookType | None): If None, clears all hooks.
@@ -141,8 +136,7 @@ class HookManager:
self._hooks[ht] = []
async def trigger(self, hook_type: HookType, context: ExecutionContext):
- """
- Invoke all hooks registered for a given lifecycle phase.
+ """Invoke all hooks registered for a given lifecycle phase.
Args:
hook_type (HookType): The lifecycle phase to trigger.
@@ -185,3 +179,10 @@ class HookManager:
hook_list = self._hooks.get(hook_type, [])
lines.append(f" {hook_type.value}: {format_hook_list(hook_list)}")
return "\n".join(lines)
+
+ def copy(self) -> HookManager:
+ """Create a deep copy of this HookManager, including all registered hooks."""
+ new_manager = HookManager()
+ for hook_type, hooks in self._hooks.items():
+ new_manager._hooks[hook_type] = list(hooks)
+ return new_manager
diff --git a/falyx/hooks.py b/falyx/hooks.py
index c26767d..43db12d 100644
--- a/falyx/hooks.py
+++ b/falyx/hooks.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines reusable lifecycle hooks for Falyx Actions and Commands.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Defines reusable lifecycle hooks for Falyx Actions and Commands.
This module includes:
- `spinner_before_hook`: Automatically starts a spinner before an action runs.
@@ -38,34 +37,34 @@ from falyx.themes import OneColors
async def spinner_before_hook(context: ExecutionContext):
"""Adds a spinner before the action starts."""
- cmd = context.action
- if cmd.options_manager is None:
+ command = context.action
+ if command.options_manager is None:
return
sm = context.action.options_manager.spinners
- if hasattr(cmd, "name"):
- cmd_name = cmd.name
+ if hasattr(command, "name"):
+ command_name = command.name
else:
- cmd_name = cmd.key
+ command_name = command.key
await sm.add(
- cmd_name,
- cmd.spinner_message,
- cmd.spinner_type,
- cmd.spinner_style,
- cmd.spinner_speed,
+ command_name,
+ command.spinner_message,
+ command.spinner_type,
+ command.spinner_style,
+ command.spinner_speed,
)
async def spinner_teardown_hook(context: ExecutionContext):
"""Removes the spinner after the action finishes (success or failure)."""
- cmd = context.action
- if cmd.options_manager is None:
+ command = context.action
+ if command.options_manager is None:
return
- if hasattr(cmd, "name"):
- cmd_name = cmd.name
+ if hasattr(command, "name"):
+ command_name = command.name
else:
- cmd_name = cmd.key
+ command_name = command.key
sm = context.action.options_manager.spinners
- await sm.remove(cmd_name)
+ await sm.remove(command_name)
class ResultReporter:
diff --git a/falyx/init.py b/falyx/init.py
index ef5d7fb..ad8c48e 100644
--- a/falyx/init.py
+++ b/falyx/init.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Project and global initializer for Falyx CLI environments.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Project and global initializer for Falyx CLI environments.
This module defines functions to bootstrap a new Falyx-based CLI project or
create a global user-level configuration in `~/.config/falyx`.
diff --git a/falyx/logger.py b/falyx/logger.py
index c90f942..cd4af8c 100644
--- a/falyx/logger.py
+++ b/falyx/logger.py
@@ -1,4 +1,4 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Global logger instance for Falyx CLI applications."""
import logging
diff --git a/falyx/menu.py b/falyx/menu.py
index 6684866..793af01 100644
--- a/falyx/menu.py
+++ b/falyx/menu.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines `MenuOption` and `MenuOptionMap`, core components used to construct
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Defines `MenuOption` and `MenuOptionMap`, core components used to construct
interactive menus within Falyx Actions such as `MenuAction` and `PromptMenuAction`.
Each `MenuOption` represents a single actionable choice with a description,
@@ -69,6 +68,14 @@ class MenuOption:
[(OneColors.WHITE, f"[{key}] "), (self.style, self.description)]
)
+ def copy(self) -> MenuOption:
+ """Create a copy of this MenuOption."""
+ return MenuOption(
+ description=self.description,
+ action=self.action.clone(),
+ style=self.style,
+ )
+
class MenuOptionMap(CaseInsensitiveDict):
"""
@@ -101,12 +108,16 @@ class MenuOptionMap(CaseInsensitiveDict):
self,
options: dict[str, MenuOption] | None = None,
allow_reserved: bool = False,
+ disable_reserved: bool = False,
):
super().__init__()
self.allow_reserved = allow_reserved
if options:
self.update(options)
- self._inject_reserved_defaults()
+ if not disable_reserved:
+ self._inject_reserved_defaults()
+ else:
+ self.allow_reserved = True
def _inject_reserved_defaults(self):
from falyx.action import SignalAction
@@ -157,3 +168,13 @@ class MenuOptionMap(CaseInsensitiveDict):
if not include_reserved and key in self.RESERVED_KEYS:
continue
yield key, option
+
+ def copy(self) -> MenuOptionMap:
+ """Create a copy of this MenuOptionMap."""
+ items = {}
+ for key, option in self.items():
+ if key in self.RESERVED_KEYS and not self.allow_reserved:
+ continue
+ items[key] = option.copy()
+ new_map = MenuOptionMap(items, allow_reserved=self.allow_reserved)
+ return new_map
diff --git a/falyx/mode.py b/falyx/mode.py
index 977e755..68ca9fc 100644
--- a/falyx/mode.py
+++ b/falyx/mode.py
@@ -1,13 +1,42 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines `FalyxMode`, an enum representing the different modes of operation for Falyx.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Runtime mode definitions for the Falyx CLI framework.
+
+This module defines `FalyxMode`, the enum used to represent the high-level
+operating mode of a Falyx application during parsing, routing, rendering, and
+execution.
+
+These modes describe the current intent of the runtime rather than any
+particular command. They are used throughout Falyx to coordinate behavior such
+as whether the application should show an interactive menu, execute a routed
+command, render help output, preview a command, or surface an error state.
+
+`FalyxMode` is commonly stored in shared runtime state and passed through
+invocation and parsing layers so UI rendering and execution flow remain
+consistent across CLI and menu-driven entrypoints.
"""
from enum import Enum
class FalyxMode(Enum):
+ """Enumerates the high-level runtime modes used by Falyx.
+
+ `FalyxMode` provides a small set of application-wide states that describe
+ how the current invocation should be handled.
+
+ Attributes:
+ MENU: Interactive menu mode using Prompt Toolkit input and menu
+ rendering.
+ COMMAND: Direct command-execution mode for routed CLI or programmatic
+ invocation.
+ PREVIEW: Non-executing preview mode used to inspect a command before it
+ runs.
+ HELP: Help-rendering mode for namespace, command, or TLDR output.
+ ERROR: Error state used when invocation handling should surface a
+ failure condition.
+ """
+
MENU = "menu"
- RUN = "run"
+ COMMAND = "command"
PREVIEW = "preview"
- RUN_ALL = "run-all"
HELP = "help"
+ ERROR = "error"
diff --git a/falyx/namespace.py b/falyx/namespace.py
new file mode 100644
index 0000000..235b73a
--- /dev/null
+++ b/falyx/namespace.py
@@ -0,0 +1,68 @@
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Namespace entry model for nested Falyx applications.
+
+This module defines `FalyxNamespace`, the lightweight metadata container used to
+register one `Falyx` instance inside another as a routed namespace entry.
+
+A `FalyxNamespace` describes how a nested application should appear and behave
+from the perspective of its parent namespace. It stores the public-facing key,
+description, aliases, styling, and visibility flags used for routing,
+completion, help rendering, and menu display, while holding a reference to the
+child `Falyx` runtime that should take over once the namespace is entered.
+
+This model is intentionally small and declarative. It does not implement
+routing, rendering, or execution itself; those responsibilities remain with the
+parent and child `Falyx` instances.
+"""
+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:
+ from falyx.falyx import Falyx
+
+
+@dataclass
+class FalyxNamespace:
+ """Represents a nested `Falyx` application exposed as a namespace entry.
+
+ `FalyxNamespace` is used by a parent `Falyx` instance to register and
+ describe a child `Falyx` runtime as a routable namespace. It provides the
+ metadata needed to expose that child namespace consistently across command
+ resolution, completion, help output, and menu rendering.
+
+ Attributes:
+ 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 (list[str]): Optional alternate names that may also resolve to the same
+ namespace.
+ 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.
+ """
+
+ key: str
+ description: str
+ namespace: Falyx
+ aliases: list[str] = field(default_factory=list)
+ help_text: str = ""
+ 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
diff --git a/falyx/options_manager.py b/falyx/options_manager.py
index bd7e327..b664c9c 100644
--- a/falyx/options_manager.py
+++ b/falyx/options_manager.py
@@ -1,80 +1,174 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Manages global or scoped CLI options across namespaces for Falyx commands.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""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., "cli_args", "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="cli_args")
+ 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 argparse import Namespace
from collections import defaultdict
-from typing import Any, Callable
+from contextlib import contextmanager
+from copy import deepcopy
+from typing import Any, Callable, Iterator, Mapping
from falyx.logger import logger
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., "cli_args") 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, Namespace]] | None = None) -> None:
- self.options: defaultdict = defaultdict(Namespace)
+ 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:
for namespace_name, namespace in namespaces:
- self.from_namespace(namespace, namespace_name)
+ self.from_mapping(namespace, namespace_name)
- def from_namespace(
- self, namespace: Namespace, namespace_name: str = "cli_args"
+ def from_mapping(
+ self,
+ values: Mapping[str, Any],
+ namespace_name: str = "default",
) -> None:
- self.options[namespace_name] = namespace
+ """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(
- self, option_name: str, default: Any = None, namespace_name: str = "cli_args"
+ self,
+ option_name: str,
+ default: Any = None,
+ namespace_name: str = "default",
) -> Any:
- """Get the value of an option."""
- return getattr(self.options[namespace_name], option_name, default)
+ """Return an option value from a namespace.
- def set(self, option_name: str, value: Any, namespace_name: str = "cli_args") -> None:
- """Set the value of an option."""
- setattr(self.options[namespace_name], option_name, value)
+ 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"`.
- def has_option(self, option_name: str, namespace_name: str = "cli_args") -> bool:
- """Check if an option exists in the namespace."""
- return hasattr(self.options[namespace_name], option_name)
+ Returns:
+ Any: The stored option value if present, otherwise `default`.
+ """
+ return self.options[namespace_name].get(option_name, default)
- def toggle(self, option_name: str, namespace_name: str = "cli_args") -> None:
- """Toggle a boolean option."""
+ def set(
+ self,
+ option_name: str,
+ value: Any,
+ namespace_name: str = "default",
+ ) -> None:
+ """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(
+ self,
+ option_name: str,
+ namespace_name: str = "default",
+ ) -> bool:
+ """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(
+ self,
+ option_name: str,
+ namespace_name: str = "default",
+ ) -> None:
+ """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(
@@ -86,9 +180,24 @@ class OptionsManager:
)
def get_value_getter(
- self, option_name: str, namespace_name: str = "cli_args"
+ self,
+ 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)
@@ -96,17 +205,108 @@ class OptionsManager:
return _getter
def get_toggle_function(
- self, option_name: str, namespace_name: str = "cli_args"
+ self,
+ 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)
return _toggle
- def get_namespace_dict(self, namespace_name: str) -> Namespace:
- """Return all options in a namespace as a dictionary."""
+ def get_namespace(self, namespace_name: str) -> dict[str, Any] | None:
+ """Return the option dictionary for a namespace.
+
+ Args:
+ namespace_name (str): Name of the namespace to retrieve.
+
+ Returns:
+ dict[str, Any]: The options stored in the requested namespace.
+ """
if namespace_name not in self.options:
- raise ValueError(f"Namespace '{namespace_name}' not found.")
- return vars(self.options[namespace_name])
+ return None
+ return self.options[namespace_name]
+
+ def get_namespace_copy(self, namespace_name: str) -> dict[str, Any] | None:
+ """Return a deep 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.
+ """
+ if namespace_name not in self.options:
+ return None
+ return deepcopy(self.options[namespace_name])
+
+ def seed_missing(
+ self,
+ defaults: Mapping[str, Any],
+ namespace_name: str = "default",
+ ) -> None:
+ """Seed missing options in a namespace from a defaults mapping.
+
+ This method only sets options that are not already present in the target
+ namespace, allowing it to be used for layering default values without
+ overwriting existing settings.
+
+ Args:
+ defaults (Mapping[str, Any]): Default option values to seed.
+ namespace_name (str): Namespace to update. Defaults to `"default"`.
+ """
+ for key, value in defaults.items():
+ if key not in self.options[namespace_name]:
+ self.options[namespace_name][key] = value
+
+ @contextmanager
+ def override_namespace(
+ self,
+ overrides: Mapping[str, Any],
+ namespace_name: str = "execution",
+ ) -> Iterator[None]:
+ """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_copy(namespace_name)
+ if original is None:
+ raise ValueError(
+ f"Cannot override non-existent namespace '{namespace_name}'."
+ )
+ try:
+ self.from_mapping(values=overrides, namespace_name=namespace_name)
+ yield
+ finally:
+ self.options[namespace_name] = original
+
+ def __str__(self) -> str:
+ return f"OptionsManager(namespaces={list(self.options.keys())})"
diff --git a/falyx/parser/__init__.py b/falyx/parser/__init__.py
index 10254ba..f200c53 100644
--- a/falyx/parser/__init__.py
+++ b/falyx/parser/__init__.py
@@ -1,21 +1,20 @@
-"""
-Falyx CLI Framework
+"""Falyx CLI Framework
-Copyright (c) 2025 rtj.dev LLC.
+Copyright (c) 2026 rtj.dev LLC.
Licensed under the MIT License. See LICENSE file for details.
"""
from .argument import Argument
from .argument_action import ArgumentAction
from .command_argument_parser import CommandArgumentParser
-from .parsers import FalyxParsers, get_arg_parsers, get_root_parser, get_subparsers
+from .falyx_parser import FalyxParser, OptionAction
+from .parse_result import ParseResult
__all__ = [
"Argument",
"ArgumentAction",
"CommandArgumentParser",
- "get_arg_parsers",
- "get_root_parser",
- "get_subparsers",
- "FalyxParsers",
+ "FalyxParser",
+ "OptionAction",
+ "ParseResult",
]
diff --git a/falyx/parser/argument.py b/falyx/parser/argument.py
index 4ecc9f7..fe9b3b2 100644
--- a/falyx/parser/argument.py
+++ b/falyx/parser/argument.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines the `Argument` dataclass used by `CommandArgumentParser` to represent
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Defines the `Argument` dataclass used by `CommandArgumentParser` to represent
individual command-line parameters in a structured, introspectable format.
Each `Argument` instance describes one CLI input, including its flags, type,
@@ -33,6 +32,8 @@ Used By:
- Rich-based CLI help generation
- Completion and preview suggestions
"""
+from __future__ import annotations
+
from dataclasses import dataclass
from typing import Any
@@ -42,8 +43,7 @@ from falyx.parser.argument_action import ArgumentAction
@dataclass
class Argument:
- """
- Represents a command-line argument.
+ """Represents a command-line argument.
Attributes:
flags (tuple[str, ...]): Short and long flags for the argument.
@@ -60,6 +60,8 @@ class Argument:
An action object that resolves the argument, if applicable.
lazy_resolver (bool): True if the resolver should be called lazily, False otherwise
suggestions (list[str] | None): Optional completions for interactive shells
+ group (str | None): Optional name of the argument group this belongs to.
+ mutex_group (str | None): Optional name of the mutually exclusive group this belongs to.
"""
flags: tuple[str, ...]
@@ -75,6 +77,8 @@ class Argument:
resolver: BaseAction | None = None
lazy_resolver: bool = False
suggestions: list[str] | None = None
+ group: str | None = None
+ mutex_group: str | None = None
def get_positional_text(self) -> str:
"""Get the positional text for the argument."""
@@ -132,6 +136,8 @@ class Argument:
and self.positional == other.positional
and self.default == other.default
and self.help == other.help
+ and self.group == other.group
+ and self.mutex_group == other.mutex_group
)
def __hash__(self) -> int:
@@ -147,5 +153,27 @@ class Argument:
self.positional,
self.default,
self.help,
+ self.group,
+ self.mutex_group,
)
)
+
+ def copy(self) -> Argument:
+ """Create a copy of this Argument."""
+ return Argument(
+ flags=self.flags,
+ dest=self.dest,
+ action=self.action,
+ type=self.type,
+ default=self.default,
+ choices=list(self.choices) if self.choices else [],
+ required=self.required,
+ help=self.help,
+ nargs=self.nargs,
+ positional=self.positional,
+ resolver=self.resolver,
+ lazy_resolver=self.lazy_resolver,
+ suggestions=list(self.suggestions) if self.suggestions else None,
+ group=self.group,
+ mutex_group=self.mutex_group,
+ )
diff --git a/falyx/parser/argument_action.py b/falyx/parser/argument_action.py
index b4db0a2..8e72a67 100644
--- a/falyx/parser/argument_action.py
+++ b/falyx/parser/argument_action.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines `ArgumentAction`, an enum used to standardize the behavior of CLI arguments
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Defines `ArgumentAction`, an enum used to standardize the behavior of CLI arguments
defined within Falyx command configurations.
Each member of this enum maps to a valid `argparse` like actions or Falyx-specific
@@ -24,8 +23,7 @@ from enum import Enum
class ArgumentAction(Enum):
- """
- Defines the action to be taken when the argument is encountered.
+ """Defines the action to be taken when the argument is encountered.
This enum mirrors the core behavior of Python's `argparse` actions, with a few
Falyx-specific extensions. It is used when defining command-line arguments for
diff --git a/falyx/parser/command_argument_parser.py b/falyx/parser/command_argument_parser.py
index 85f8654..0fedeee 100644
--- a/falyx/parser/command_argument_parser.py
+++ b/falyx/parser/command_argument_parser.py
@@ -1,73 +1,163 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-This module implements `CommandArgumentParser`, a flexible, rich-aware alternative to
-argparse tailored specifically for Falyx CLI workflows. It provides structured parsing,
-type coercion, flag support, and usage/help rendering for CLI-defined commands.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""CommandArgumentParser for the Falyx CLI framework.
-Unlike argparse, this parser is lightweight, introspectable, and designed to integrate
-deeply with Falyx's Action system, including support for lazy execution and resolver
-binding via `BaseAction`.
+This module provides a structured, extensible argument parsing system designed
+specifically for Falyx commands. It replaces traditional argparse usage with a
+parser that is deeply integrated with Falyx's execution model, including support
+for Actions, execution options, and interactive completion.
+
+The parser is designed to:
+- Define command arguments declaratively via `add_argument`
+- Support both positional and keyword-style flags
+- Perform type coercion and validation
+- Separate execution-level options (e.g. retries, confirmation) from command inputs
+- Integrate with Falyx lifecycle and Action-based execution
+- Provide rich help rendering and interactive suggestions
Key Features:
-- Declarative argument registration via `add_argument()`
-- Support for positional and keyword flags, type coercion, default values
-- Enum- and action-driven argument semantics via `ArgumentAction`
-- Lazy evaluation of arguments using Falyx `Action` resolvers
-- Optional value completion via suggestions and choices
-- Rich-powered help rendering with grouped display
-- Optional boolean flags via `--flag` / `--no-flag`
-- POSIX-style bundling for single-character flags (`-abc`)
-- Partial parsing for completions and validation via `suggest_next()`
+- Positional and flagged argument support
+- Type coercion via configurable `type` handlers
+- Enum-driven behavior via `ArgumentAction`
+- Lazy and eager resolution using BaseAction resolvers
+- Execution option support (e.g. retries, summary, confirm flags)
+- Mutually exclusive and grouped argument definitions
+- POSIX-style short flag bundling (e.g. `-abc`)
+- Interactive suggestions via `suggest_next`
+- Rich-based help and TLDR rendering
-Public Interface:
-- `add_argument(...)`: Register a new argument with type, flags, and behavior.
-- `parse_args(...)`: Parse CLI-style argument list into a `dict[str, Any]`.
-- `parse_args_split(...)`: Return `(*args, **kwargs)` for Action invocation.
-- `render_help()`: Render a rich-styled help panel.
-- `render_tldr()`: Render quick usage examples.
-- `suggest_next(...)`: Return suggested flags or values for completion.
+Core Parsing APIs:
+- `parse_args(...)`:
+ Parse arguments into a resolved dictionary of values
+- `parse_args_split(...)`:
+ Split parsed results into `(args, kwargs, execution_args)` for execution
+- `add_argument(...)`:
+ Register argument definitions declaratively
+- `suggest_next(...)`:
+ Provide completion suggestions for interactive input
-Example Usage:
- parser = CommandArgumentParser(command_key="D")
- parser.add_argument("--env", choices=["prod", "dev"], required=True)
- parser.add_argument("path", type=Path)
+Design Principles:
+- Minimal surface area compared to argparse
+- Strong integration with Falyx execution model
+- Predictable and explicit parsing behavior
+- Separation of parsing, execution, and runtime configuration
- args = await parser.parse_args(["--env", "prod", "./config.yml"])
-
- # args == {'env': 'prod', 'path': Path('./config.yml')}
-
- parser.render_help() # Pretty Rich output
-
-Design Notes:
-This parser intentionally omits argparse-style groups, metavar support,
-and complex multi-level conflict handling. Instead, it favors:
-- Simplicity
-- Completeness
-- Falyx-specific integration (hooks, lifecycle, and error surfaces)
+This parser is intended for use exclusively within Falyx and is not a
+general-purpose argparse replacement.
"""
from __future__ import annotations
+import re
from collections import Counter, defaultdict
+from collections.abc import Callable
from copy import deepcopy
from pathlib import Path
-from typing import Any, 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.exceptions import CommandArgumentError
+from falyx.context import InvocationContext
+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
from falyx.parser.argument import Argument
from falyx.parser.argument_action import ArgumentAction
-from falyx.parser.parser_types import ArgumentState, TLDRExample, false_none, true_none
-from falyx.parser.utils import coerce_value
+from falyx.parser.group import ArgumentGroup, MutuallyExclusiveGroup
+from falyx.parser.parser_types import (
+ ArgumentState,
+ TLDRExample,
+ TLDRInput,
+ false_none,
+ true_none,
+)
+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.
+
+ This lightweight wrapper preserves the normal `add_argument()` API while
+ injecting `group` or `mutex_group` metadata into each registered argument.
+
+ Args:
+ parser (CommandArgumentParser): Parser that owns the group definitions.
+ group_name (str | None): Name of the argument group to assign.
+ mutex_name (str | None): Name of the mutually exclusive group to assign.
+ """
+
+ def __init__(
+ self,
+ parser: CommandArgumentParser,
+ *,
+ group_name: str | None = None,
+ mutex_name: str | None = None,
+ ) -> None:
+ 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,
+ action: str | ArgumentAction = "store",
+ nargs: int | str | None = None,
+ default: Any = None,
+ type: Callable[[Any], 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,
+ ) -> Argument:
+ return 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,
+ )
+
+ 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:
"""
@@ -90,45 +180,144 @@ class CommandArgumentParser:
- Render Help using Rich library.
"""
- RESERVED_DESTS = frozenset(("help", "tldr"))
+ RESERVED_DESTS = frozenset({"help", "tldr"})
def __init__(
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,
- tldr_examples: list[tuple[str, str]] | None = None,
+ tldr_examples: list[TLDRInput] | None = None,
program: str | None = None,
options_manager: OptionsManager | None = None,
- _is_help_command: bool = False,
) -> None:
"""Initialize the 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 []
self.program: str | None = program
+
self._arguments: list[Argument] = []
self._positional: dict[str, Argument] = {}
self._keyword: dict[str, Argument] = {}
self._keyword_list: list[Argument] = []
self._flag_map: dict[str, Argument] = {}
self._dest_set: set[str] = set()
+ self._execution_dests: set[str] = set()
+
self._add_help()
self._last_positional_states: dict[str, ArgumentState] = {}
self._last_keyword_states: dict[str, ArgumentState] = {}
+
+ self._argument_groups: dict[str, ArgumentGroup] = {}
+ self._mutex_groups: dict[str, MutuallyExclusiveGroup] = {}
+ self._arg_group_by_dest: dict[str, str] = {}
+ self._mutex_group_by_dest: dict[str, str] = {}
+
self._tldr_examples: list[TLDRExample] = []
- self._is_help_command: bool = _is_help_command
+ self._is_help_command: bool = False
if tldr_examples:
self.add_tldr_examples(tldr_examples)
self.options_manager: OptionsManager = options_manager or OptionsManager()
+ self._is_runner_mode: bool = False
+ self._summary_enabled: bool = False
+ self._retries_enabled: bool = False
+ self._confirm_enabled: 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):
+ raise NotAFalyxError("options_manager must be an instance of OptionsManager")
+ self.options_manager = options_manager
+
+ def enable_execution_options(
+ self,
+ execution_options: frozenset[ExecutionOption],
+ ) -> None:
+ """Enable support for execution options like retries, summary, etc."""
+ if ExecutionOption.SUMMARY in execution_options and not self._summary_enabled:
+ self.add_argument(
+ "--summary",
+ action=ArgumentAction.STORE_TRUE,
+ help="Print an execution summary after command completes",
+ )
+ self._register_execution_dest("summary")
+ self._summary_enabled = True
+
+ if ExecutionOption.RETRY in execution_options and not self._retries_enabled:
+ self.add_argument(
+ "--retries",
+ type=int,
+ help="Number of retries on failure",
+ default=0,
+ )
+ self._register_execution_dest("retries")
+ self.add_argument(
+ "--retry-delay",
+ type=float,
+ default=0.0,
+ help="Initial delay between retries in seconds",
+ )
+ self._register_execution_dest("retry_delay")
+ self.add_argument(
+ "--retry-backoff",
+ type=float,
+ default=0.0,
+ help="Backoff multiplier for retries (e.g. 2.0 doubles the delay each retry)",
+ )
+ self._register_execution_dest("retry_backoff")
+ self._retries_enabled = True
+
+ if ExecutionOption.CONFIRM in execution_options and not self._confirm_enabled:
+ self.add_argument(
+ "--confirm",
+ dest="force_confirm",
+ action=ArgumentAction.STORE_TRUE,
+ help="Force confirmation prompts",
+ )
+ self._register_execution_dest("force_confirm")
+ self.add_argument(
+ "--skip-confirm",
+ action=ArgumentAction.STORE_TRUE,
+ help="Skip confirmation prompts",
+ )
+ self._register_execution_dest("skip_confirm")
+ self._confirm_enabled = True
+
+ def _register_execution_dest(self, dest: str) -> None:
+ """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"
+ )
+ self._execution_dests.add(dest)
+
+ def _is_execution_dest(self, dest: str) -> bool:
+ """Check if a destination is registered as an execution argument."""
+ return dest in self._execution_dests
+
def _add_help(self):
"""Add help argument to the parser."""
help = Argument(
@@ -136,34 +325,76 @@ class CommandArgumentParser:
action=ArgumentAction.HELP,
help="Show this help message.",
dest="help",
+ choices=[],
)
self._register_argument(help)
- def add_tldr_examples(self, examples: list[tuple[str, str]]) -> None:
- """
- Add TLDR examples to the parser.
+ def _add_tldr(self):
+ """Add TLDR argument to the parser."""
+ tldr = Argument(
+ flags=("--tldr", "-T"),
+ action=ArgumentAction.TLDR,
+ help="Show quick usage examples.",
+ dest="tldr",
+ choices=[],
+ )
+ self._register_argument(tldr)
+
+ def add_tldr_example(self, usage: str, description: str) -> None:
+ """Add a single TLDR example to the parser."""
+ self._tldr_examples.append(TLDRExample(usage=usage, description=description))
+ if "tldr" not in self._dest_set:
+ self._add_tldr()
+
+ def add_tldr_examples(self, examples: list[TLDRInput]) -> None:
+ """Add TLDR examples to the parser.
Args:
- examples (list[tuple[str, str]]): List of (usage, description) tuples.
+ examples (list[TLDRInput]): List of TLDRExample instances or (usage, description) tuples.
"""
- if not all(
- isinstance(example, tuple) and len(example) == 2 for example in examples
- ):
- raise CommandArgumentError(
- "TLDR examples must be a list of (usage, description) tuples"
- )
-
- for usage, description in examples:
- self._tldr_examples.append(TLDRExample(usage=usage, description=description))
+ for example in examples:
+ if isinstance(example, TLDRExample):
+ self._tldr_examples.append(example)
+ elif isinstance(example, tuple) and len(example) == 2:
+ usage, description = example
+ self._tldr_examples.append(
+ TLDRExample(usage=usage, description=description)
+ )
+ else:
+ raise CommandArgumentError(
+ 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:
- tldr = Argument(
- ("--tldr", "-T"),
- action=ArgumentAction.TLDR,
- help="Show quick usage examples.",
- dest="tldr",
- )
- self._register_argument(tldr)
+ self._add_tldr()
+
+ def add_argument_group(
+ self,
+ name: str,
+ description: str = "",
+ ) -> _GroupBuilder:
+ if name in self._argument_groups:
+ raise ArgumentGroupError(f"argument group '{name}' already exists")
+ self._argument_groups[name] = ArgumentGroup(name=name, description=description)
+ return _GroupBuilder(self, group_name=name)
+
+ def add_mutually_exclusive_group(
+ self,
+ name: str,
+ *,
+ required: bool = False,
+ description: str = "",
+ ) -> _GroupBuilder:
+ if name in self._mutex_groups:
+ raise ArgumentGroupError(f"mutex group '{name}' already exists")
+ self._mutex_groups[name] = MutuallyExclusiveGroup(
+ name=name,
+ required=required,
+ description=description,
+ )
+ return _GroupBuilder(self, mutex_name=name)
def _is_positional(self, flags: tuple[str, ...]) -> bool:
"""Check if the flags are positional."""
@@ -172,35 +403,68 @@ 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(
+ self,
+ group: str | None,
+ mutex_group: str | None,
+ positional: bool = False,
+ required: bool = False,
+ ) -> None:
+ """Validate that the specified groups exist and are compatible."""
+ if group is not None:
+ if group not in self._argument_groups:
+ raise ArgumentGroupError(f"argument group '{group}' does not exist")
+
+ if mutex_group is not None:
+ if mutex_group not in self._mutex_groups:
+ raise ArgumentGroupError(
+ f"mutually exclusive group '{mutex_group}' does not exist"
+ )
+
+ if positional and mutex_group is not None:
+ raise ArgumentGroupError(
+ "positional arguments cannot belong to a mutually exclusive group"
+ )
+
+ if required and mutex_group is not None:
+ 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:
"""Convert flags to a destination name."""
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(
@@ -220,7 +484,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:
@@ -256,7 +520,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:
@@ -267,7 +531,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
@@ -276,14 +540,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")
@@ -293,41 +559,47 @@ class CommandArgumentParser:
raise CommandArgumentError(
"choices must be iterable (like list, tuple, or set)"
) from error
- else:
- choices = []
+ normalized: list[Any] = []
for choice in choices:
try:
- coerce_value(choice, expected_type)
+ normalized.append(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
+ return normalized
- def _validate_default_type(
- self, default: Any, expected_type: type, dest: str
- ) -> None:
- """Validate the default value type."""
- if default is not None:
+ def _normalize_default_type(
+ self, default: Any, expected_type: Callable[[Any], Any], dest: str
+ ) -> Any:
+ """Normalize the default value type."""
+ if default is None:
+ return None
+ try:
+ return coerce_value(default, expected_type)
+ except Exception as error:
+ type_name = get_type_name(expected_type)
+ raise CommandArgumentError(
+ f"invalid default value {default!r} for '{dest}' cannot be coerced to {type_name} error: {error}"
+ ) from error
+
+ def _normalize_default_list_type(
+ self, default: list[Any], expected_type: Callable[[Any], Any], dest: str
+ ) -> list[Any] | None:
+ """Normalize the default value type for a list."""
+ if not isinstance(default, list):
+ return None
+ normalized: list[Any] = []
+ for item in default:
try:
- coerce_value(default, expected_type)
+ normalized.append(coerce_value(item, 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 list value {default!r} for '{dest}' cannot be coerced to {type_name} error: {error}"
) from error
-
- def _validate_default_list_type(
- self, default: list[Any], expected_type: type, dest: str
- ) -> None:
- """Validate the default value type for a list."""
- if isinstance(default, list):
- for item in default:
- try:
- coerce_value(item, expected_type)
- except Exception as error:
- raise CommandArgumentError(
- f"Default list value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__} error: {error}"
- ) from error
+ return normalized
def _validate_resolver(
self, action: ArgumentAction, resolver: BaseAction | None
@@ -339,7 +611,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):
@@ -355,7 +627,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,
@@ -367,7 +640,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
@@ -394,49 +667,59 @@ 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"
+ )
+ if not re.match(r"^[a-zA-Z0-9_-]+$", flag.lstrip("-")):
+ raise CommandArgumentError(
+ f"invalid flag '{flag}': must only contain letters, digits, underscores, or hyphens"
)
def _register_store_bool_optional(
@@ -444,7 +727,9 @@ class CommandArgumentParser:
flags: tuple[str, ...],
dest: str,
help: str,
- ) -> None:
+ group: str | None,
+ mutex_group: str | None,
+ ) -> Argument:
"""Register a store_bool_optional action with the parser."""
if len(flags) != 1:
raise CommandArgumentError(
@@ -464,8 +749,9 @@ class CommandArgumentParser:
type=true_none,
default=None,
help=help,
+ group=group,
+ mutex_group=mutex_group,
)
-
negated_argument = Argument(
flags=(negated_flag,),
dest=dest,
@@ -473,10 +759,13 @@ class CommandArgumentParser:
type=false_none,
default=None,
help=help,
+ group=group,
+ mutex_group=mutex_group,
)
self._register_argument(argument)
self._register_argument(negated_argument, bypass_validation=True)
+ return argument
def _register_argument(
self, argument: Argument, bypass_validation: bool = False
@@ -486,7 +775,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:
@@ -503,13 +792,21 @@ class CommandArgumentParser:
else:
self._keyword_list.append(argument)
+ if argument.group:
+ self._arg_group_by_dest[argument.dest] = argument.group
+ self._argument_groups[argument.group].dests.add(argument.dest)
+
+ if argument.mutex_group:
+ self._mutex_group_by_dest[argument.dest] = argument.mutex_group
+ self._mutex_groups[argument.mutex_group].dests.add(argument.dest)
+
def add_argument(
self,
- *flags,
+ *flags: str,
action: str | ArgumentAction = "store",
nargs: int | str | None = None,
default: Any = None,
- type: Any = str,
+ type: Callable[[Any], Any] = str,
choices: Iterable | None = None,
required: bool = False,
help: str = "",
@@ -517,9 +814,10 @@ class CommandArgumentParser:
resolver: BaseAction | None = None,
lazy_resolver: bool = True,
suggestions: list[str] | None = None,
- ) -> None:
- """
- Define a new argument for the parser.
+ group: str | None = None,
+ mutex_group: str | None = None,
+ ) -> Argument:
+ """Define a new argument for the parser.
Supports positional and flagged arguments, type coercion, default values,
validation rules, and optional resolution via `BaseAction`.
@@ -537,21 +835,25 @@ class CommandArgumentParser:
resolver (BaseAction | None): If action="action", the BaseAction to call.
lazy_resolver (bool): If True, resolver defers until action is triggered.
suggestions (list[str] | None): Optional suggestions for interactive completion.
+ 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)
+
action = self._validate_action(action, positional)
resolver = self._validate_resolver(action, resolver)
@@ -562,53 +864,63 @@ class CommandArgumentParser:
and default is not None
):
if isinstance(default, list):
- self._validate_default_list_type(default, expected_type, dest)
+ default = self._normalize_default_list_type(default, type, dest)
else:
- self._validate_default_type(default, expected_type, dest)
- choices = self._normalize_choices(choices, expected_type, action)
+ default = self._normalize_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)
- else:
- argument = Argument(
- flags=flags,
- dest=dest,
- action=action,
- type=expected_type,
- default=default,
- choices=choices,
- required=required,
- help=help,
- nargs=nargs,
- positional=positional,
- resolver=resolver,
- lazy_resolver=lazy_resolver,
- suggestions=suggestions,
+ return self._register_store_bool_optional(
+ flags, dest, help, group, mutex_group
)
- self._register_argument(argument)
+ argument = Argument(
+ flags=flags,
+ dest=dest,
+ action=action,
+ type=type,
+ default=default,
+ choices=choices,
+ required=required,
+ help=help,
+ nargs=nargs,
+ positional=positional,
+ resolver=resolver,
+ lazy_resolver=lazy_resolver,
+ suggestions=suggestions,
+ group=group,
+ mutex_group=mutex_group,
+ )
+ self._register_argument(argument)
+ return argument
def get_argument(self, dest: str) -> Argument | None:
- """
- Return the Argument object for a given destination name.
+ """Return the Argument object for a given destination name.
Args:
dest (str): Destination key of the argument.
@@ -621,8 +933,7 @@ class CommandArgumentParser:
)
def to_definition_list(self) -> list[dict[str, Any]]:
- """
- Convert argument metadata into a serializable list of dicts.
+ """Convert argument metadata into a serializable list of dicts.
Returns:
List of definitions for use in config introspection, documentation, or export.
@@ -635,12 +946,15 @@ class CommandArgumentParser:
"dest": arg.dest,
"action": arg.action,
"type": arg.type,
+ "default": arg.default,
"choices": arg.choices,
"required": arg.required,
+ "help": arg.help,
"nargs": arg.nargs,
"positional": arg.positional,
- "default": arg.default,
- "help": arg.help,
+ "suggestions": arg.suggestions,
+ "group": arg.group,
+ "mutex_group": arg.mutex_group,
}
)
return defs
@@ -655,6 +969,8 @@ class CommandArgumentParser:
if not spec.choices:
return None
value_check = result.get(spec.dest)
+ if not self._is_present(spec, value_check):
+ return None
if isinstance(value_check, list):
if all(value in spec.choices for value in value_check):
return None
@@ -662,8 +978,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(
@@ -679,14 +996,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
@@ -699,13 +1009,23 @@ class CommandArgumentParser:
and spec.nargs in ("+", "*", "?")
), f"Invalid nargs value: {spec.nargs}"
values = []
+ display_name = spec.flags[0] if spec.flags else spec.dest
if isinstance(spec.nargs, int):
+ if index + spec.nargs > len(args):
+ raise MissingValueError(
+ dest=spec.dest,
+ expected_count=spec.nargs,
+ actual_count=len(args) - index,
+ display_name=display_name,
+ )
values = args[index : index + spec.nargs]
return values, index + spec.nargs
elif spec.nargs == "+":
if index >= len(args):
- raise CommandArgumentError(
- f"Expected at least one value for '{spec.dest}'"
+ raise MissingValueError(
+ dest=spec.dest,
+ expected_count="+",
+ display_name=display_name,
)
while index < len(args) and args[index] not in self._keyword:
values.append(args[index])
@@ -744,7 +1064,6 @@ class CommandArgumentParser:
if spec_index not in consumed_positional_indicies
]
index = 0
-
for spec_index, spec in remaining_positional_args:
# estimate how many args the remaining specs might need
is_last = spec_index == len(positional_args) - 1
@@ -779,7 +1098,6 @@ class CommandArgumentParser:
)
values, new_index = self._consume_nargs(slice_args, 0, spec)
index += new_index
-
try:
typed = [coerce_value(value, spec.type) for value in values]
except Exception as error:
@@ -791,19 +1109,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 MissingValueError(
+ dest=spec.dest,
+ expected_count="+",
+ )
+ if isinstance(spec.nargs, int) and len(typed) != spec.nargs:
+ raise MissingValueError(
+ dest=spec.dest,
+ expected_count=spec.nargs,
+ actual_count=len(typed),
+ )
if not spec.lazy_resolver or not from_validate:
try:
result[spec.dest] = await spec.resolver(*typed)
except Exception as error:
- raise CommandArgumentError(
- f"[{spec.dest}] Action failed: {error}"
+ raise ArgumentParsingError(
+ f"[{spec.dest}] action failed: {error}"
) from error
self._check_if_in_choices(spec, result, arg_states)
arg_states[spec.dest].set_consumed(base_index + index)
@@ -831,15 +1158,14 @@ class CommandArgumentParser:
if spec.nargs not in ("*", "+"):
consumed_positional_indicies.add(spec_index)
-
if index < len(args):
if len(args[index:]) == 1 and args[index].startswith("-"):
token = args[index]
self._raise_remaining_args_error(token, arg_states)
else:
plural = "s" if len(args[index:]) > 1 else ""
- raise CommandArgumentError(
- f"Unexpected positional argument{plural}: {', '.join(args[index:])}"
+ raise ArgumentParsingError(
+ f"unexpected positional argument{plural}: {', '.join(args[index:])}"
)
return index
@@ -876,7 +1202,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
@@ -906,21 +1232,22 @@ class CommandArgumentParser:
if choices:
choices.append(help_text)
choices_text = ", ".join(choices)
- raise CommandArgumentError(
- f"Argument '{spec.dest}' requires a value. {choices_text}"
+ raise ArgumentParsingError(
+ f"argument '{spec.dest}' requires a value. {choices_text}"
)
elif spec.nargs is None:
try:
- raise CommandArgumentError(
- f"Enter a {spec.type.__name__} value for '{spec.dest}'. {help_text}"
+ type_name = get_type_name(spec.type)
+ raise ArgumentParsingError(
+ f"enter a {type_name} value for '{spec.dest}'. {help_text}"
)
except AttributeError as error:
- raise CommandArgumentError(
- f"Enter a value for '{spec.dest}'. {help_text}"
+ raise ArgumentParsingError(
+ f"enter a value for '{spec.dest}'. {help_text}"
) from error
else:
- raise CommandArgumentError(
- f"Argument '{spec.dest}' requires a value. Expected {spec.nargs} values. {help_text}"
+ raise ArgumentParsingError(
+ f"argument '{spec.dest}' requires a value. Expected {spec.nargs} values. {help_text}"
)
async def _handle_token(
@@ -934,6 +1261,7 @@ class CommandArgumentParser:
consumed_indices: set[int],
arg_states: dict[str, ArgumentState],
from_validate: bool = False,
+ invocation_context: InvocationContext | None = None,
) -> int:
"""Handle a single token in the command line arguments."""
if token in self._keyword:
@@ -942,7 +1270,7 @@ class CommandArgumentParser:
if action == ArgumentAction.HELP:
if not from_validate:
- self.render_help()
+ self.render_help(invocation_context)
arg_states[spec.dest].set_consumed()
raise HelpSignal()
elif action == ArgumentAction.TLDR:
@@ -952,7 +1280,7 @@ class CommandArgumentParser:
consumed_indices.add(index)
index += 1
elif not from_validate:
- self.render_tldr()
+ self.render_tldr(invocation_context)
arg_states[spec.dest].set_consumed()
raise HelpSignal()
else:
@@ -968,15 +1296,13 @@ 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)
except Exception as error:
- raise CommandArgumentError(
- f"[{spec.dest}] Action failed: {error}"
+ raise ArgumentParsingError(
+ f"[{spec.dest}] action failed: {error}"
) from error
self._check_if_in_choices(spec, result, arg_states)
arg_states[spec.dest].set_consumed(new_index)
@@ -1009,9 +1335,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:
@@ -1028,9 +1352,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
@@ -1041,9 +1363,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, "?"):
@@ -1103,18 +1423,93 @@ class CommandArgumentParser:
args[expand_index : expand_index + 1] = expand_token
expand_index += len(expand_token) if isinstance(expand_token, list) else 1
- async def parse_args(
- self, args: list[str] | None = None, from_validate: bool = False
- ) -> dict[str, Any]:
+ def _is_present(self, spec: Argument, value: Any) -> bool:
"""
- Parse arguments into a dictionary of resolved values.
+ Presence means 'user actually selected/provided this', not merely that
+ a default exists.
+ """
+ if spec.action == ArgumentAction.STORE_TRUE:
+ return value is True
+ if spec.action == ArgumentAction.STORE_FALSE:
+ return value is False
+ if spec.action == ArgumentAction.STORE_BOOL_OPTIONAL:
+ return value is not None
+ if spec.action == ArgumentAction.COUNT:
+ return bool(value)
+ if spec.action in (ArgumentAction.APPEND, ArgumentAction.EXTEND):
+ return bool(value)
+ return value is not None
+
+ def _validate_mutex_groups(self, result: dict[str, Any]) -> None:
+ for group in self._mutex_groups.values():
+ present: list[str] = []
+
+ for dest in group.dests:
+ spec = self.get_argument(dest)
+ if spec is None:
+ continue
+ if self._is_present(spec, result.get(dest)):
+ present.append(dest)
+
+ if len(present) > 1:
+ raise ArgumentParsingError(
+ f"arguments in mutually exclusive group '{group.name}' "
+ f"cannot be used together: {', '.join(present)}"
+ )
+
+ if group.required and not present:
+ members = []
+ for dest in group.dests:
+ spec = self.get_argument(dest)
+ if spec:
+ members.append(spec.flags[0] if spec.flags else dest)
+ raise ArgumentParsingError(
+ f"one of the following is required for group '{group.name}': "
+ f"{', '.join(members)}"
+ )
+
+ async def parse_args(
+ self,
+ args: list[str] | None = None,
+ from_validate: bool = False,
+ invocation_context: InvocationContext | None = None,
+ ) -> dict[str, Any]:
+ """Parse CLI arguments into a resolved mapping of values.
+
+ This method parses the provided CLI-style tokens and returns a dictionary
+ mapping argument destinations to their resolved values. It performs full
+ validation, type coercion, default handling, and resolver execution.
+
+ Unlike `parse_args_split`, this method returns a unified mapping of all
+ parsed arguments, including both command arguments and execution options.
+
+ Behavior:
+ - Parses positional and keyword arguments based on registered definitions
+ - Applies type coercion via configured `type` handlers
+ - Resolves values using BaseAction resolvers (if defined)
+ - Validates required arguments, choices, and mutual exclusion constraints
+ - Applies default values for missing optional arguments
+ - Supports validation mode (`from_validate=True`) for interactive contexts
Args:
- args (list[str]): The CLI-style argument list.
- from_validate (bool): If True, enables relaxed resolution for validation mode.
+ args (list[str]): CLI-style argument tokens to parse.
+ from_validate (bool): Whether parsing is occurring in validation mode
+ (e.g. prompt_toolkit validator). When True, may defer certain
+ resolution steps or suppress eager failures.
Returns:
- dict[str, Any]: Parsed argument result mapping.
+ dict[str, Any]: Mapping of argument destination names to resolved values.
+
+ Raises:
+ CommandArgumentError: If parsing, validation, or coercion fails.
+ HelpSignal: If help or TLDR output is triggered during parsing.
+
+ Notes:
+ - This method returns a flat mapping of all arguments.
+ - Use `parse_args_split` when separating execution options from
+ command arguments is required for execution.
+ - This is the primary parsing entrypoint used internally by
+ `parse_args_split`.
"""
if args is None:
args = []
@@ -1149,8 +1544,30 @@ class CommandArgumentParser:
consumed_indices,
arg_states=arg_states,
from_validate=from_validate,
+ invocation_context=invocation_context,
)
+ # Compare length of args with length of required positional arguments to catch missing required positionals
+ if len(args) < len(
+ [
+ arg
+ for arg in self._arguments
+ if (arg.positional and arg.required and not arg.default)
+ ]
+ ):
+ missing_positionals = [
+ arg.dest
+ for arg in self._arguments
+ if arg.positional
+ and arg.required
+ and arg.dest not in consumed_positional_indices
+ and not arg.default
+ ]
+ if missing_positionals:
+ raise ArgumentParsingError(
+ f"missing positional argument(s): {', '.join(missing_positionals)}"
+ )
+
# Required validation
for spec in self._arguments:
if spec.dest == "help" or spec.dest == "tldr":
@@ -1164,13 +1581,13 @@ class CommandArgumentParser:
):
if not args:
arg_states[spec.dest].reset()
- raise CommandArgumentError(
- f"Missing required argument '{spec.dest}': {spec.get_choice_text()}{help_text}"
+ raise ArgumentParsingError(
+ f"missing required argument '{spec.dest}': {spec.get_choice_text()}{help_text}"
)
continue # Lazy resolvers are not validated here
arg_states[spec.dest].reset()
- raise CommandArgumentError(
- f"Missing required argument '{spec.dest}': {spec.get_choice_text()}{help_text}"
+ raise ArgumentParsingError(
+ f"missing required argument '{spec.dest}': {spec.get_choice_text()}{help_text}"
)
self._check_if_in_choices(spec, result, arg_states)
@@ -1181,47 +1598,83 @@ 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 == "+":
+ assert isinstance(
+ result.get(spec.dest), list
+ ), f"Invalid value for '{spec.dest}': expected a list"
+ if not result[spec.dest] and not spec.required:
+ continue
+ help_text = f" help: {spec.help}" if spec.help else ""
+ if not result[spec.dest]:
+ arg_states[spec.dest].reset()
+ raise ArgumentParsingError(
+ f"argument '{spec.dest}' requires at least one value{help_text}"
+ )
+
+ self._validate_mutex_groups(result)
+
result.pop("help", None)
if not self._is_help_command:
result.pop("tldr", None)
return result
async def parse_args_split(
- self, args: list[str], from_validate: bool = False
- ) -> tuple[tuple[Any, ...], dict[str, Any]]:
- """
- Parse arguments and return both positional and keyword mappings.
+ self,
+ args: list[str],
+ from_validate: bool = False,
+ invocation_context: InvocationContext | None = None,
+ ) -> tuple[tuple[Any, ...], dict[str, Any], dict[str, Any]]:
+ """Parse arguments and split them into execution-ready components.
- Useful for function-style calling with `*args, **kwargs`.
+ This method parses the provided CLI-style tokens and separates the resolved
+ values into three categories:
+
+ - positional arguments for `*args`
+ - keyword arguments for `**kwargs`
+ - execution arguments for Falyx runtime behavior
+
+ Execution arguments are options such as retries, confirmation flags, or
+ summary output that should not be passed to the underlying action.
+
+ Args:
+ args (list[str]): CLI-style argument tokens to parse.
+ from_validate (bool): Whether parsing is occurring in validation mode.
Returns:
- tuple: (args tuple, kwargs dict)
+ tuple:
+ - tuple[Any, ...]: Positional arguments for execution.
+ - dict[str, Any]: Keyword arguments for execution.
+ - dict[str, Any]: Execution-specific arguments handled by Falyx.
"""
- parsed = await self.parse_args(args, from_validate)
+ parsed = await self.parse_args(args, from_validate, invocation_context)
args_list = []
kwargs_dict = {}
+ execution_dict = {}
for arg in self._arguments:
if arg.dest == "help":
continue
@@ -1229,9 +1682,11 @@ class CommandArgumentParser:
continue
if arg.positional:
args_list.append(parsed[arg.dest])
+ elif self._is_execution_dest(arg.dest):
+ execution_dict[arg.dest] = parsed[arg.dest]
else:
kwargs_dict[arg.dest] = parsed[arg.dest]
- return tuple(args_list), kwargs_dict
+ return tuple(args_list), kwargs_dict, execution_dict
def _suggest_paths(self, stub: str) -> list[str]:
"""Return filesystem path suggestions based on a stub."""
@@ -1320,20 +1775,57 @@ class CommandArgumentParser:
return self._suggest_paths(prefix if not cursor_at_end_of_token else ".")
return []
+ def _filter_mutex_flags(
+ self,
+ remaining_flags: list[str],
+ consumed_dests: list[str],
+ ) -> list[str]:
+ active_mutex_groups = {
+ self._mutex_group_by_dest[dest]
+ for dest in consumed_dests
+ if dest in self._mutex_group_by_dest
+ }
+
+ if not active_mutex_groups:
+ return remaining_flags
+
+ filtered: list[str] = []
+ for flag in remaining_flags:
+ arg = self._keyword[flag]
+ mutex_name = self._mutex_group_by_dest.get(arg.dest)
+ if (
+ mutex_name
+ and mutex_name in active_mutex_groups
+ and arg.dest not in consumed_dests
+ ):
+ continue
+ filtered.append(flag)
+
+ return filtered
+
def suggest_next(
self, args: list[str], cursor_at_end_of_token: bool = False
) -> list[str]:
- """
- Suggest completions for the next argument based on current input.
+ """Suggest valid completions for the current argument state.
- This is used for interactive shell completion or prompt_toolkit integration.
+ This method analyzes the partially entered argument list and returns
+ context-aware suggestions for the next token. Suggestions may include:
+
+ - remaining flags
+ - valid choices for the current argument
+ - configured custom suggestions
+ - filesystem paths for `Path`-typed arguments
+
+ It supports positional arguments, flagged arguments, multi-value arguments,
+ POSIX short-flag bundling, and mutually exclusive group filtering.
Args:
args (list[str]): Current partial argument tokens.
- cursor_at_end_of_token (bool): True if space at end of args
+ cursor_at_end_of_token (bool): Whether the cursor is positioned after a
+ completed token (for example, after a trailing space).
Returns:
- list[str]: List of suggested completions.
+ list[str]: Sorted completion suggestions valid for the current parse state.
"""
self._resolve_posix_bundling(args)
last = args[-1] if args else ""
@@ -1406,6 +1898,7 @@ class CommandArgumentParser:
remaining_flags = [
flag for flag, arg in self._keyword.items() if arg.dest not in consumed_dests
]
+ remaining_flags = self._filter_mutex_flags(remaining_flags, consumed_dests)
last_keyword_state_in_args = None
last_keyword = None
@@ -1603,9 +2096,8 @@ class CommandArgumentParser:
return sorted(set(suggestions))
- def get_options_text(self, plain_text=False) -> str:
- """
- Render all defined arguments as a help-style string.
+ def get_options_text(self) -> str:
+ """Render all defined arguments as a help-style string.
Returns:
str: A visual description of argument flags and structure.
@@ -1625,66 +2117,127 @@ class CommandArgumentParser:
choice_text = arg.get_choice_text()
if isinstance(arg.nargs, int):
choice_text = " ".join([choice_text] * arg.nargs)
- if plain_text:
- options_list.append(choice_text)
- else:
- options_list.append(escape(choice_text))
+ options_list.append(escape(choice_text))
return " ".join(options_list)
- def get_command_keys_text(self, plain_text=False) -> str:
- """
- Return formatted string showing the command key and aliases.
+ def get_command_keys_text(self) -> str:
+ """Return formatted string showing the command key and aliases.
Used in help rendering and introspection.
Returns:
str: The visual command selector line.
"""
- if plain_text:
- command_keys = " | ".join(
- [f"{self.command_key}"] + [f"{alias}" for alias in self.aliases]
- )
- else:
- command_keys = " | ".join(
- [f"[{self.command_style}]{self.command_key}[/{self.command_style}]"]
- + [
- f"[{self.command_style}]{alias}[/{self.command_style}]"
- for alias in self.aliases
- ]
- )
+ command_keys = " | ".join(
+ [f"[{self.command_style}]{self.command_key}[/{self.command_style}]"]
+ + [
+ f"[{self.command_style}]{alias}[/{self.command_style}]"
+ for alias in self.aliases
+ ]
+ )
return command_keys
- def get_usage(self, plain_text=False) -> str:
- """
- Render the usage string for this parser.
+ def _get_invocation_prefix(
+ self,
+ invocation_context: InvocationContext | None = None,
+ ) -> str:
+ if invocation_context is None:
+ command_keys = self.get_command_keys_text()
+ if self.options_manager.get("mode") == FalyxMode.MENU:
+ return command_keys
+
+ program = self.program or "falyx"
+ 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:
+ return invocation_context.markup_path
+
+ return invocation_context.markup_path
+
+ def get_usage(
+ self,
+ invocation_context: InvocationContext | None = None,
+ ) -> str:
+ """Render the usage string for this parser.
Returns:
str: A formatted usage line showing syntax and argument structure.
"""
- command_keys = self.get_command_keys_text(plain_text)
- options_text = self.get_options_text(plain_text)
- if options_text:
- return f"{command_keys} {options_text}"
- return command_keys
+ prefix = self._get_invocation_prefix(invocation_context)
+ options_text = self.get_options_text()
+ return f"{prefix} {options_text}".strip() if options_text else prefix
- def render_help(self) -> None:
+ 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.
"""
- Print formatted help text for this command using Rich output.
+ usage = self.get_usage(invocation_context)
+ self.console.print(f"[bold]usage:[/bold] {usage}")
- Includes usage, description, argument groups, and optional epilog.
+ def _iter_keyword_help_sections(
+ self,
+ ) -> Generator[tuple[str, str, list[Argument]], None, None]:
+ """Yields (title, description, arguments)"""
+ assigned = set()
+
+ for group in self._argument_groups.values():
+ args = []
+ for dest in group.dests:
+ spec = self.get_argument(dest)
+ if spec and not spec.positional:
+ args.append(spec)
+ assigned.add(dest)
+ if args:
+ yield group.name, group.description, args
+
+ ungrouped = []
+ for arg in self._keyword_list:
+ if arg.dest not in assigned:
+ ungrouped.append(arg)
+
+ if ungrouped:
+ yield "options", "", ungrouped
+
+ def render_help(
+ self,
+ invocation_context: InvocationContext | None = None,
+ ) -> None:
+ """Render full help output for the command.
+
+ This method displays a complete help view for the command, including
+ usage, description, argument definitions, execution options, and any
+ additional help text.
+
+ The output is formatted using Rich and is intended for both CLI and
+ interactive menu contexts.
+
+ Behavior:
+ - Renders a usage string derived from the parser configuration
+ - Displays command description, aliases, and optional epilog text
+ - Lists positional and keyword arguments with types, defaults, and help text
+ - Supports argument grouping and mutually exclusive groups
+ - Applies styling based on configured command style
"""
- usage = self.get_usage()
- self.console.print(f"[bold]usage: {usage}[/bold]\n")
+ self.render_usage(invocation_context)
- # Description
if self.help_text:
- self.console.print(self.help_text + "\n")
+ self.console.print(f"\n{self.help_text}")
- # Arguments
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} "
@@ -1692,74 +2245,71 @@ class CommandArgumentParser:
if help_text and len(flags) > 30:
help_text = f"\n{'':<33}{help_text}"
self.console.print(f"{arg_line}{help_text}")
- self.console.print("[bold]options:[/bold]")
- arg_groups = defaultdict(list)
- for arg in self._keyword_list:
- arg_groups[arg.dest].append(arg)
- for group in arg_groups.values():
- if len(group) == 2 and all(
- arg.action == ArgumentAction.STORE_BOOL_OPTIONAL for arg in group
- ):
- # Merge --flag / --no-flag pair into single help line for STORE_BOOL_OPTIONAL
- all_flags = tuple(
- sorted(
- (arg.flags[0] for arg in group),
- key=lambda f: f.startswith("--no-"),
+ for title, description, args in self._iter_keyword_help_sections():
+ self.console.print(f"\n[bold]{title}:[/bold]")
+ if description:
+ self.console.print(f" [dim]{description}[/dim]")
+
+ arg_groups: defaultdict[str, list[Argument]] = defaultdict(list)
+ for arg in args:
+ arg_groups[arg.dest].append(arg)
+
+ for group in arg_groups.values():
+ if len(group) == 2 and all(
+ arg.action == ArgumentAction.STORE_BOOL_OPTIONAL for arg in group
+ ):
+ # Merge --flag / --no-flag pair into single help line for STORE_BOOL_OPTIONAL
+ all_flags = tuple(
+ sorted(
+ (arg.flags[0] for arg in group),
+ key=lambda f: f.startswith("--no-"),
+ )
)
- )
- else:
- all_flags = group[0].flags
+ else:
+ all_flags = group[0].flags
- flags = ", ".join(all_flags)
- flags_choice = f"{flags} {group[0].get_choice_text()}"
- arg_line = f" {flags_choice:<30} "
- help_text = group[0].help or ""
- if help_text and len(flags_choice) > 30:
- help_text = f"\n{'':<33}{help_text}"
- self.console.print(f"{arg_line}{help_text}")
+ suffix = ""
+ mutex_name = group[0].mutex_group
+ if mutex_name:
+ suffix = f" [dim]({mutex_name})[/dim]"
+ flags = ", ".join(all_flags)
+ flags_choice = f"{flags} {group[0].get_choice_text()}"
+ arg_line = f" {flags_choice:<30} "
+ help_text = f"{group[0].help or ''}{suffix}"
+ if help_text and len(flags_choice) > 30:
+ help_text = f"\n{'':<33}{help_text}"
+ self.console.print(f"{arg_line}{help_text}")
- # Epilog
if self.help_epilog:
self.console.print("\n" + self.help_epilog, style="dim")
- def render_tldr(self) -> None:
- """
- Print TLDR examples for this command using Rich output.
+ def render_tldr(self, invocation_context: InvocationContext | None = None) -> None:
+ """Render concise example usage (TLDR) for the command.
- Displays brief usage examples with descriptions.
+ This method displays a minimal, example-driven view of how to invoke
+ the command. It is intended as a quick-start reference rather than a
+ complete specification.
+
+ Notes:
+ - TLDR output is designed for speed and clarity, not completeness.
+ - Typically invoked via `--tldr` or equivalent help flags.
+ - Complements `render_help`, which provides full documentation.
"""
if not self._tldr_examples:
self.console.print(
f"[bold]No TLDR examples available for {self.command_key}.[/bold]"
)
return
- is_cli_mode = self.options_manager.get("mode") in {
- FalyxMode.RUN,
- FalyxMode.PREVIEW,
- FalyxMode.RUN_ALL,
- FalyxMode.HELP,
- }
- program = self.program or "falyx"
- command = self.aliases[0] if self.aliases else self.command_key
- if self._is_help_command and is_cli_mode:
- command = f"[{self.command_style}]{program} help[/{self.command_style}]"
- elif is_cli_mode:
- command = (
- f"[{self.command_style}]{program} run {command}[/{self.command_style}]"
- )
- else:
- command = f"[{self.command_style}]{command}[/{self.command_style}]"
-
- usage = self.get_usage()
- self.console.print(f"[bold]usage:[/] {usage}\n")
+ prefix = self._get_invocation_prefix(invocation_context)
+ 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"{command} {example.usage.strip()}"
+ usage = f"{prefix} {example.usage.strip()}"
description = example.description.strip()
block = f"[bold]{usage}[/bold]"
self.console.print(
@@ -1793,3 +2343,63 @@ class CommandArgumentParser:
def __repr__(self) -> str:
return str(self)
+
+ def clone_with_overrides(
+ self,
+ *,
+ command_key: str | None = None,
+ command_description: str | None = None,
+ command_style: StyleType | None = None,
+ help_text: str | None = None,
+ help_epilog: str | None = None,
+ aliases: list[str] | None = None,
+ tldr_examples: list[TLDRInput] | None = None,
+ program: str | None = None,
+ options_manager: OptionsManager | None = None,
+ ) -> CommandArgumentParser:
+ """Create a copy of this parser with optional overrides for core properties."""
+ if tldr_examples is not None:
+ tldr_examples_copied = tldr_examples
+ elif self._tldr_examples is not None:
+ tldr_examples_copied = [example.copy() for example in self._tldr_examples]
+ else:
+ tldr_examples_copied = None
+ parser = CommandArgumentParser(
+ command_key=command_key if command_key is not None else self.command_key,
+ command_description=(
+ command_description
+ if command_description is not None
+ else self.command_description
+ ),
+ command_style=(
+ command_style if command_style is not None else self.command_style
+ ),
+ help_text=help_text if help_text is not None else self.help_text,
+ help_epilog=help_epilog if help_epilog is not None else self.help_epilog,
+ aliases=aliases if aliases is not None else self.aliases.copy(),
+ program=program if program is not None else self.program,
+ tldr_examples=tldr_examples_copied,
+ options_manager=(
+ options_manager if options_manager is not None else self.options_manager
+ ),
+ )
+
+ parser._argument_groups = {
+ name: group.copy() for name, group in self._argument_groups.items()
+ }
+ parser._mutex_groups = {
+ name: group.copy() for name, group in self._mutex_groups.items()
+ }
+
+ for argument in self._arguments:
+ if argument.dest in {"help", "tldr"}:
+ continue
+ parser._register_argument(argument.copy())
+
+ parser._execution_dests = set(self._execution_dests)
+ parser._summary_enabled = self._summary_enabled
+ parser._retries_enabled = self._retries_enabled
+ parser._confirm_enabled = self._confirm_enabled
+ parser._is_runner_mode = self._is_runner_mode
+ parser._is_help_command = self._is_help_command
+ return parser
diff --git a/falyx/parser/falyx_parser.py b/falyx/parser/falyx_parser.py
new file mode 100644
index 0000000..b1d89b8
--- /dev/null
+++ b/falyx/parser/falyx_parser.py
@@ -0,0 +1,677 @@
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+from __future__ import annotations
+
+import re
+from collections.abc import Callable
+from typing import TYPE_CHECKING, Any
+
+from falyx.exceptions import EntryNotFoundError, FalyxOptionError
+from falyx.mode import FalyxMode
+from falyx.parser.option import Option, OptionScope
+from falyx.parser.option_action import OptionAction
+from falyx.parser.parse_result import ParseResult
+from falyx.parser.parser_types import (
+ FalyxTLDRExample,
+ FalyxTLDRInput,
+ OptionState,
+ 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 FalyxParser:
+ RESERVED_DESTS: set[str] = {"help", "tldr"}
+
+ 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.help_option: Option | None = None
+ self.tldr_option: Option | None = None
+ self._last_option_states: dict[str, OptionState] = {}
+ self._add_reserved_options()
+
+ 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]
+
+ def get_options(self) -> list[Option]:
+ """Return a list of registered options."""
+ return self._options
+
+ def _add_tldr(self):
+ """Add TLDR option to the parser."""
+ if "tldr" in self._dest_set:
+ return None
+ tldr = Option(
+ flags=("--tldr", "-T"),
+ action=OptionAction.TLDR,
+ help="Show quick usage examples.",
+ dest="tldr",
+ default=False,
+ )
+ self._register_option(tldr)
+ self.tldr_option = tldr
+
+ def add_tldr_example(
+ self,
+ *,
+ entry_key: str,
+ usage: str,
+ description: str,
+ ) -> None:
+ """Register a single namespace-level TLDR example.
+
+ The referenced entry must resolve to a known command or namespace in the
+ current `Falyx` instance. Unknown entries are reported to the console and
+ are not added.
+
+ Args:
+ entry_key (str): Command or namespace key the example is associated with.
+ usage (str): Example usage fragment shown after the resolved invocation path.
+ description (str): Short explanation displayed alongside the example.
+
+ Raises:
+ EntryNotFoundError: If `entry_key` cannot be resolved to a known command or
+ namespace in this `Falyx` instance.
+ """
+ entry, suggestions = self._flx.resolve_entry(entry_key)
+ if not entry:
+ raise EntryNotFoundError(
+ unknown_name=entry_key,
+ suggestions=suggestions,
+ message_context="TLDR example",
+ )
+ self._tldr_examples.append(
+ FalyxTLDRExample(entry_key=entry_key, usage=usage, description=description)
+ )
+ self._add_tldr()
+
+ def add_tldr_examples(self, examples: list[FalyxTLDRInput]) -> None:
+ """Register multiple namespace-level TLDR examples.
+
+ Supports either `FalyxTLDRExample` objects or shorthand tuples of
+ `(entry_key, usage, description)`.
+
+ Args:
+ examples (list[FalyxTLDRInput]): Example definitions to validate and append.
+
+ Raises:
+ FalyxError: If an example has an unsupported shape.
+ EntryNotFoundError: If `entry_key` cannot be resolved to a known command or
+ namespace in this `Falyx` instance.
+ """
+ for example in examples:
+ if isinstance(example, FalyxTLDRExample):
+ entry, suggestions = self._flx.resolve_entry(example.entry_key)
+ if not entry:
+ raise EntryNotFoundError(
+ unknown_name=example.entry_key,
+ suggestions=suggestions,
+ message_context="TLDR example",
+ )
+ self._tldr_examples.append(example)
+ self._add_tldr()
+ elif len(example) == 3:
+ entry_key, usage, description = example
+ self.add_tldr_example(
+ entry_key=entry_key,
+ usage=usage,
+ description=description,
+ )
+ self._add_tldr()
+ else:
+ raise FalyxOptionError(
+ f"invalid TLDR example format: {example}.\n"
+ "examples must be either FalyxTLDRExample instances "
+ "or tuples of (entry_key, usage, description).",
+ )
+
+ def _add_reserved_options(self) -> None:
+ help = Option(
+ flags=("-h", "--help"),
+ dest="help",
+ action=OptionAction.HELP,
+ help="Show root-level help output and exit.",
+ default=False,
+ )
+ self._register_option(help)
+ self.help_option = help
+
+ if not self._flx.disable_verbose_option:
+ verbose = Option(
+ flags=("-v", "--verbose"),
+ dest="verbose",
+ action=OptionAction.STORE_TRUE,
+ help="Enable verbose logging for the session.",
+ default=False,
+ scope=OptionScope.ROOT,
+ )
+ self._register_option(verbose)
+
+ if not self._flx.disable_debug_hooks_option:
+ debug_hooks = Option(
+ flags=("-d", "--debug-hooks"),
+ dest="debug_hooks",
+ action=OptionAction.STORE_TRUE,
+ help="Log hook execution in detail for the session.",
+ default=False,
+ scope=OptionScope.ROOT,
+ )
+ self._register_option(debug_hooks)
+
+ if not self._flx.disable_never_prompt_option:
+ never_prompt = Option(
+ flags=("-n", "--never-prompt"),
+ dest="never_prompt",
+ action=OptionAction.STORE_TRUE,
+ help="Suppress all prompts for the session.",
+ default=False,
+ scope=OptionScope.ROOT,
+ )
+ self._register_option(never_prompt)
+
+ def _register_store_bool_optional(
+ self,
+ flags: tuple[str, ...],
+ dest: str,
+ help: str,
+ ) -> Option:
+ """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('-')}"
+
+ option = Option(
+ flags=flags,
+ dest=dest,
+ action=OptionAction.STORE_BOOL_OPTIONAL,
+ type=true_none,
+ default=None,
+ help=help,
+ )
+
+ negated_option = Option(
+ flags=(negated_flag,),
+ dest=dest,
+ action=OptionAction.STORE_BOOL_OPTIONAL,
+ type=false_none,
+ default=None,
+ help=help,
+ )
+
+ self._register_option(option)
+ self._register_option(negated_option, bypass_validation=True)
+ return option
+
+ def _register_option(self, option: Option, bypass_validation: bool = False) -> None:
+ self._dest_set.add(option.dest)
+ self._options.append(option)
+ self._last_option_states[option.dest] = OptionState(option)
+ for flag in option.flags:
+ if flag in self._options_by_dest and not bypass_validation:
+ existing = self._options_by_dest[flag]
+ raise FalyxOptionError(
+ f"flag '{flag}' is already used by option '{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 option '{existing.dest}'"
+ )
+ if not re.match(r"^[a-zA-Z0-9_-]+$", flag.lstrip("-")):
+ raise FalyxOptionError(
+ f"invalid flag '{flag}': must only contain letters, digits, underscores, or hyphens"
+ )
+
+ 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 _normalize_default_type(
+ self,
+ default: Any,
+ expected_type: Any,
+ dest: str,
+ ) -> Any:
+ if default is None:
+ return None
+ try:
+ return 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: Callable[[Any], Any],
+ 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
+ normalized: list[Any] = []
+ for choice in choices:
+ try:
+ normalized.append(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 normalized
+
+ def add_option(
+ self,
+ *flags: str,
+ action: str | OptionAction = "store",
+ default: Any = None,
+ type: Callable[[Any], Any] = str,
+ choices: list[str] | None = None,
+ help: str = "",
+ dest: str | None = None,
+ suggestions: list[str] | None = None,
+ ) -> Option:
+ 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)
+
+ if action is OptionAction.STORE:
+ default = self._normalize_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:
+ return self._register_store_bool_optional(flags, dest, help)
+ option = Option(
+ flags=flags,
+ dest=dest,
+ action=action,
+ type=type,
+ default=default,
+ choices=choices,
+ help=help,
+ suggestions=suggestions,
+ )
+ self._register_option(option)
+ return option
+
+ def _filter_suggestions(
+ self,
+ suggestion: str,
+ prefix: str,
+ cursor_at_end_of_token: bool,
+ ) -> bool:
+ if cursor_at_end_of_token:
+ return True
+ return suggestion.startswith(prefix)
+
+ def _value_suggestions_for_option(
+ self,
+ option: Option,
+ prefix: str,
+ cursor_at_end_of_token: bool,
+ ) -> list[str]:
+ if option.choices:
+ return [
+ str(choice)
+ for choice in option.choices
+ if self._filter_suggestions(str(choice), prefix, cursor_at_end_of_token)
+ ]
+ if option.suggestions:
+ return [
+ suggestion
+ for suggestion in option.suggestions
+ if self._filter_suggestions(suggestion, prefix, cursor_at_end_of_token)
+ ]
+ return []
+
+ def suggest_next(
+ self,
+ args: list[str],
+ cursor_at_end_of_token: bool,
+ ) -> tuple[list[str], bool]:
+ """Suggest the next possible flags based on the current input stub."""
+ expecting_value = False
+ if not args:
+ return [], expecting_value
+ options = self._resolve_posix_bundling(args)
+ consumed_dests = [
+ state.option.dest
+ for state in self._last_option_states.values()
+ if state.consumed
+ ]
+
+ remaining_flags = [
+ flag
+ for flag, option in self._options_by_dest.items()
+ if option.dest not in consumed_dests
+ ]
+
+ last = options[-1] if options else ""
+
+ last_option_in_options = None
+ for option in reversed(options):
+ if option in self._options_by_dest:
+ last_option_in_options = self._options_by_dest[option]
+ break
+
+ suggestions: list[str] = []
+ if last.startswith("-") and last not in self._options_by_dest:
+ suggestions.extend(flag for flag in remaining_flags if flag.startswith(last))
+ elif (
+ last_option_in_options
+ and not self._last_option_states[last_option_in_options.dest].consumed
+ ):
+ suggestions.extend(
+ self._value_suggestions_for_option(
+ last_option_in_options,
+ prefix=last,
+ cursor_at_end_of_token=cursor_at_end_of_token,
+ )
+ )
+ if last_option_in_options.action is OptionAction.STORE:
+ expecting_value = True
+
+ return suggestions, expecting_value
+
+ def _can_bundle_option(self, option: Option) -> bool:
+ return option.action in {
+ OptionAction.STORE_TRUE,
+ OptionAction.STORE_FALSE,
+ OptionAction.COUNT,
+ OptionAction.HELP,
+ OptionAction.TLDR,
+ }
+
+ def _resolve_posix_bundling(self, tokens: list[str]) -> list[str]:
+ """Expand POSIX-style bundled options into separate options."""
+ expanded: list[str] = []
+ for token in tokens:
+ if not token.startswith("-") or token.startswith("--") or len(token) <= 2:
+ expanded.append(token)
+ continue
+
+ bundle = [f"-{char}" for char in token[1:]]
+
+ if (
+ all(
+ flag in self._options_by_dest
+ and self._can_bundle_option(self._options_by_dest[flag])
+ for flag in bundle[:-1]
+ )
+ and bundle[-1] in self._options_by_dest
+ ):
+ expanded.extend(bundle)
+ else:
+ expanded.append(token)
+ return expanded
+
+ def _default_values(self) -> tuple[dict[str, Any], dict[str, Any]]:
+ values: dict[str, Any] = {}
+ root_values: dict[str, Any] = {}
+
+ for option in self._options:
+ if option.scope == OptionScope.ROOT:
+ root_values[option.dest] = option.default
+ elif option.scope == OptionScope.NAMESPACE:
+ values.setdefault(option.dest, option.default)
+ else:
+ assert False, f"unhandled option scope: {option.scope}"
+
+ return values, root_values
+
+ def _consume_option(
+ self,
+ option: Option,
+ argv: list[str],
+ index: int,
+ values: dict[str, Any],
+ option_states: dict[str, OptionState],
+ ) -> int:
+ match option.action:
+ case OptionAction.STORE_TRUE:
+ values[option.dest] = True
+ option_states[option.dest].set_consumed()
+ return index + 1
+
+ case OptionAction.STORE_FALSE:
+ values[option.dest] = False
+ option_states[option.dest].set_consumed()
+ return index + 1
+
+ case OptionAction.STORE_BOOL_OPTIONAL:
+ values[option.dest] = option.type(True)
+ option_states[option.dest].set_consumed()
+ return index + 1
+
+ case OptionAction.COUNT:
+ values[option.dest] = int(values.get(option.dest) or 0) + 1
+ option_states[option.dest].set_consumed()
+ return index + 1
+
+ case OptionAction.HELP:
+ values[option.dest] = True
+ option_states[option.dest].set_consumed()
+ return index + 1
+
+ case OptionAction.TLDR:
+ values[option.dest] = True
+ option_states[option.dest].set_consumed()
+ return index + 1
+
+ case OptionAction.STORE:
+ value_index = index + 1
+ if value_index >= len(argv):
+ raise FalyxOptionError(f"option '{argv[index]}' expected a value")
+
+ raw_value = argv[value_index]
+ try:
+ value = coerce_value(raw_value, option.type)
+ except Exception as error:
+ raise FalyxOptionError(
+ f"invalid value for '{argv[index]}': {error}"
+ ) from error
+
+ if option.choices and value not in option.choices:
+ choices = ", ".join(str(choice) for choice in option.choices)
+ raise FalyxOptionError(
+ f"invalid value for '{argv[index]}': expected one of {{{choices}}}"
+ )
+
+ values[option.dest] = value
+ option_states[option.dest].set_consumed()
+ return index + 2
+
+ raise FalyxOptionError(f"unsupported option action: {option.action}")
+
+ def parse_args(
+ self,
+ argv: list[str] | None = None,
+ ) -> ParseResult:
+ option_states = {option.dest: OptionState(option) for option in self._options}
+ self._last_option_states = option_states
+ raw_argv = argv or []
+ arguments = self._resolve_posix_bundling(raw_argv)
+ root_options: dict[str, Any] = {}
+ namespace_options: dict[str, Any] = {}
+
+ index = 0
+ while index < len(arguments):
+ token = arguments[index]
+
+ # 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_options if option.scope == OptionScope.ROOT else namespace_options
+ )
+ index = self._consume_option(
+ option,
+ arguments,
+ index,
+ target_values,
+ option_states,
+ )
+
+ remaining_argv = arguments[index:]
+
+ help_requested = namespace_options.get("help", False) or namespace_options.get(
+ "tldr", False
+ )
+
+ namespace_defaults, root_defaults = self._default_values()
+ return ParseResult(
+ mode=FalyxMode.HELP if help_requested else FalyxMode.COMMAND,
+ raw_argv=raw_argv,
+ root_defaults=root_defaults,
+ root_options=root_options,
+ namespace_defaults=namespace_defaults,
+ namespace_options=namespace_options,
+ remaining_argv=remaining_argv,
+ help=namespace_options.get("help", False),
+ tldr=namespace_options.get("tldr", False),
+ current_head=remaining_argv[0] if remaining_argv else "",
+ )
diff --git a/falyx/parser/group.py b/falyx/parser/group.py
new file mode 100644
index 0000000..4a5a01d
--- /dev/null
+++ b/falyx/parser/group.py
@@ -0,0 +1,93 @@
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Argument grouping models for the Falyx command argument parser.
+
+This module defines lightweight dataclasses used by
+`CommandArgumentParser` to organize arguments into named help sections and
+mutually exclusive sets.
+
+It provides:
+
+- `ArgumentGroup`, which represents a logical collection of related argument
+ destinations for grouped help rendering.
+- `MutuallyExclusiveGroup`, which represents a set of argument destinations
+ where only one member may be selected, with optional group-level
+ requiredness.
+
+These models are metadata containers only. They do not perform parsing or
+validation themselves. Instead, they are populated and enforced by
+`CommandArgumentParser` during argument registration, parsing, and help
+generation.
+
+This module exists to keep argument-group state explicit, structured, and easy
+to introspect.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+
+@dataclass(slots=True)
+class ArgumentGroup:
+ """Represents a named group of related command argument destinations.
+
+ `ArgumentGroup` is used by `CommandArgumentParser` to collect arguments that
+ belong together conceptually so they can be rendered under a shared section
+ in help output and tracked as a unit in parser metadata.
+
+ This class stores only grouping metadata and does not implement any parsing
+ behavior on its own.
+
+ Attributes:
+ name: User-facing name of the argument group.
+ description: Optional descriptive text for the group, typically used in
+ help rendering.
+ dests: Destination names of arguments assigned to this group.
+ """
+
+ name: str
+ description: str = ""
+ dests: set[str] = field(default_factory=set)
+
+ def copy(self) -> ArgumentGroup:
+ """Create a copy of this ArgumentGroup."""
+ return ArgumentGroup(
+ name=self.name,
+ description=self.description,
+ dests=set(self.dests),
+ )
+
+
+@dataclass(slots=True)
+class MutuallyExclusiveGroup:
+ """Represents a mutually exclusive set of argument destinations.
+
+ `MutuallyExclusiveGroup` is used by `CommandArgumentParser` to model groups
+ of arguments where only one member may be provided at a time. It can also
+ mark the group as required, meaning that exactly one of the grouped
+ arguments must be present.
+
+ This class stores group metadata only. Validation and enforcement are
+ performed by the parser.
+
+ Attributes:
+ name: User-facing name of the mutually exclusive group.
+ required: Whether at least one argument in the group must be supplied.
+ description: Optional descriptive text for the group, typically used in
+ help rendering.
+ dests: Destination names of arguments assigned to this mutually
+ exclusive group.
+ """
+
+ name: str
+ required: bool = False
+ description: str = ""
+ dests: set[str] = field(default_factory=set)
+
+ def copy(self) -> MutuallyExclusiveGroup:
+ """Create a copy of this MutuallyExclusiveGroup."""
+ return MutuallyExclusiveGroup(
+ name=self.name,
+ required=self.required,
+ description=self.description,
+ dests=set(self.dests),
+ )
diff --git a/falyx/parser/option.py b/falyx/parser/option.py
new file mode 100644
index 0000000..9cb054d
--- /dev/null
+++ b/falyx/parser/option.py
@@ -0,0 +1,41 @@
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import Enum
+from typing import Any
+
+from falyx.parser.option_action import OptionAction
+
+
+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 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
+
+ def format_for_help(self) -> str:
+ """Return a formatted string of the option's flags for help output."""
+ return ", ".join(self.flags)
diff --git a/falyx/parser/option_action.py b/falyx/parser/option_action.py
new file mode 100644
index 0000000..67774c9
--- /dev/null
+++ b/falyx/parser/option_action.py
@@ -0,0 +1,44 @@
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+from __future__ import annotations
+
+from enum import Enum
+
+
+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 option 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 option action."""
+ return self.value
diff --git a/falyx/parser/parse_result.py b/falyx/parser/parse_result.py
new file mode 100644
index 0000000..3ce73a2
--- /dev/null
+++ b/falyx/parser/parse_result.py
@@ -0,0 +1,65 @@
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Parse result model for the Falyx CLI runtime.
+
+This module defines `ParseResult`, the normalized output produced by the
+root-level Falyx parsing stage.
+
+`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.
+
+This model is typically produced by `FalyxParser.parse()` and then consumed by
+higher-level Falyx runtime entrypoints such as `Falyx.run()` to configure
+logging, prompt behavior, help rendering, and routed command dispatch.
+
+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 ParseResult:
+ """Represents the normalized result of root-level Falyx argument parsing.
+
+ `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.
+
+ This model is used to communicate root parsing decisions cleanly to the
+ rest of the Falyx runtime, including whether the application should enter
+ help mode or continue with normal command execution.
+
+ Attributes:
+ mode: Top-level runtime mode selected from the root parse.
+ raw_argv: Original argv passed into the root parser.
+ root_defaults: Dictionary of parsed root-level options and their default values.
+ root_options: Dictionary of parsed root-level options that should be
+ applied at the root level for all namespaces.
+ namespace_defaults: Dictionary of parsed namespace-level options and their default values.
+ namespace_options: Dictionary of parsed namespace-level options and their values.
+ 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.
+ """
+
+ mode: FalyxMode
+ raw_argv: list[str] = field(default_factory=list)
+ root_defaults: dict[str, Any] = field(default_factory=dict)
+ root_options: dict[str, Any] = field(default_factory=dict)
+ namespace_defaults: dict[str, Any] = field(default_factory=dict)
+ namespace_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
diff --git a/falyx/parser/parser_types.py b/falyx/parser/parser_types.py
index c803cf4..40af413 100644
--- a/falyx/parser/parser_types.py
+++ b/falyx/parser/parser_types.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Type utilities and argument state models for Falyx's custom CLI argument parser.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Type utilities and argument state models for Falyx's custom CLI argument parser.
This module provides specialized helpers and data structures used by
the `CommandArgumentParser` to handle non-standard parsing behavior.
@@ -16,21 +15,16 @@ Contents:
These tools support richer expressiveness and user-friendly ergonomics in
Falyx's declarative command-line interfaces.
"""
+from __future__ import annotations
+
from dataclasses import dataclass
-from typing import Any
+from typing import Any, TypeAlias
from falyx.parser.argument import Argument
+from falyx.parser.option import Option
-@dataclass
-class ArgumentState:
- """Tracks an argument and whether it has been consumed."""
-
- arg: Argument
- consumed: bool = False
- consumed_position: int | None = None
- has_invalid_choice: bool = False
-
+class StateMixin:
def set_consumed(self, position: int | None = None) -> None:
"""Mark this argument as consumed, optionally setting the position."""
self.consumed = True
@@ -42,6 +36,26 @@ class ArgumentState:
self.consumed_position = None
+@dataclass
+class ArgumentState(StateMixin):
+ """Tracks an argument and whether it has been consumed."""
+
+ arg: Argument
+ consumed: bool = False
+ consumed_position: int | None = None
+ has_invalid_choice: bool = False
+
+
+@dataclass
+class OptionState(StateMixin):
+ """Tracks an option argument and its consumed state, including the dest name."""
+
+ option: Option
+ consumed: bool = False
+ consumed_position: int | None = None
+ has_invalid_choice: bool = False
+
+
@dataclass(frozen=True)
class TLDRExample:
"""Represents a usage example for TLDR output."""
@@ -49,6 +63,36 @@ class TLDRExample:
usage: str
description: str
+ def copy(self) -> TLDRExample:
+ """Create a copy of this TLDRExample."""
+ return TLDRExample(
+ usage=self.usage,
+ description=self.description,
+ )
+
+
+TLDRInput: TypeAlias = TLDRExample | tuple[str, str]
+
+
+@dataclass(frozen=True)
+class FalyxTLDRExample:
+ """Represents a usage example for Falyx TLDR output, with optional metadata."""
+
+ entry_key: str
+ usage: str
+ description: str
+
+ def copy(self) -> FalyxTLDRExample:
+ """Create a copy of this FalyxTLDRExample."""
+ return FalyxTLDRExample(
+ entry_key=self.entry_key,
+ usage=self.usage,
+ description=self.description,
+ )
+
+
+FalyxTLDRInput: TypeAlias = FalyxTLDRExample | tuple[str, str, str]
+
def true_none(value: Any) -> bool | None:
"""Return True if value is not None, else None."""
diff --git a/falyx/parser/parsers.py b/falyx/parser/parsers.py
deleted file mode 100644
index ce3e75f..0000000
--- a/falyx/parser/parsers.py
+++ /dev/null
@@ -1,408 +0,0 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Provides the argument parser infrastructure for the Falyx CLI.
-
-This module defines the `FalyxParsers` dataclass and related utilities for building
-structured CLI interfaces with argparse. It supports top-level CLI commands like
-`run`, `run-all`, `preview`, `help`, and `version`, and integrates seamlessly with
-registered `Command` objects for dynamic help, usage generation, and argument handling.
-
-Key Components:
-- `FalyxParsers`: Container for all CLI subparsers.
-- `get_arg_parsers()`: Factory for generating full parser suite.
-- `get_root_parser()`: Creates the root-level CLI parser with global options.
-- `get_subparsers()`: Helper to attach subcommand parsers to the root parser.
-
-Used internally by the Falyx CLI `run()` entry point to parse arguments and route
-execution across commands and workflows.
-"""
-
-from argparse import (
- REMAINDER,
- ArgumentParser,
- Namespace,
- RawDescriptionHelpFormatter,
- _SubParsersAction,
-)
-from dataclasses import asdict, dataclass
-from typing import Any, Sequence
-
-from falyx.command import Command
-
-
-@dataclass
-class FalyxParsers:
- """Defines the argument parsers for the Falyx CLI."""
-
- root: ArgumentParser
- subparsers: _SubParsersAction
- run: ArgumentParser
- run_all: ArgumentParser
- preview: ArgumentParser
- help: ArgumentParser
- version: ArgumentParser
-
- def parse_args(self, args: Sequence[str] | None = None) -> Namespace:
- """Parse the command line arguments."""
- return self.root.parse_args(args)
-
- def as_dict(self) -> dict[str, ArgumentParser]:
- """Convert the FalyxParsers instance to a dictionary."""
- return asdict(self)
-
- def get_parser(self, name: str) -> ArgumentParser | None:
- """Get the parser by name."""
- return self.as_dict().get(name)
-
-
-def get_root_parser(
- prog: str | None = "falyx",
- usage: str | None = None,
- description: str | None = "Falyx CLI - Run structured async command workflows.",
- epilog: str | None = "Tip: Use 'falyx help' to show available commands.",
- parents: Sequence[ArgumentParser] | None = None,
- prefix_chars: str = "-",
- fromfile_prefix_chars: str | None = None,
- argument_default: Any = None,
- conflict_handler: str = "error",
- add_help: bool = True,
- allow_abbrev: bool = True,
- exit_on_error: bool = True,
-) -> ArgumentParser:
- """
- Construct the root-level ArgumentParser for the Falyx CLI.
-
- This parser handles global arguments shared across subcommands and can serve
- as the base parser for the Falyx CLI or standalone applications. It includes
- options for verbosity, debug logging, and version output.
-
- Args:
- prog (str | None): Name of the program (e.g., 'falyx').
- usage (str | None): Optional custom usage string.
- description (str | None): Description shown in the CLI help.
- epilog (str | None): Message displayed at the end of help output.
- parents (Sequence[ArgumentParser] | None): Optional parent parsers.
- prefix_chars (str): Characters to denote optional arguments (default: "-").
- fromfile_prefix_chars (str | None): Prefix to indicate argument file input.
- argument_default (Any): Global default value for arguments.
- conflict_handler (str): Strategy to resolve conflicting argument names.
- add_help (bool): Whether to include help (`-h/--help`) in this parser.
- allow_abbrev (bool): Allow abbreviated long options.
- exit_on_error (bool): Exit immediately on error or raise an exception.
-
- Returns:
- ArgumentParser: The root parser with global options attached.
-
- Notes:
- ```
- Includes the following arguments:
- --never-prompt : Run in non-interactive mode.
- -v / --verbose : Enable debug logging.
- --debug-hooks : Enable hook lifecycle debug logs.
- --version : Print the Falyx version.
- ```
- """
- parser = ArgumentParser(
- prog=prog,
- usage=usage,
- description=description,
- epilog=epilog,
- parents=parents if parents else [],
- prefix_chars=prefix_chars,
- fromfile_prefix_chars=fromfile_prefix_chars,
- argument_default=argument_default,
- conflict_handler=conflict_handler,
- add_help=add_help,
- allow_abbrev=allow_abbrev,
- exit_on_error=exit_on_error,
- )
- parser.add_argument(
- "--never-prompt",
- action="store_true",
- help="Run in non-interactive mode with all prompts bypassed.",
- )
- parser.add_argument(
- "-v", "--verbose", action="store_true", help=f"Enable debug logging for {prog}."
- )
- parser.add_argument(
- "--debug-hooks",
- action="store_true",
- help="Enable default lifecycle debug logging",
- )
- parser.add_argument("--version", action="store_true", help=f"Show {prog} version")
- return parser
-
-
-def get_subparsers(
- parser: ArgumentParser,
- title: str = "Falyx Commands",
- description: str | None = "Available commands for the Falyx CLI.",
-) -> _SubParsersAction:
- """
- Create and return a subparsers object for registering Falyx CLI subcommands.
-
- This function adds a `subparsers` block to the given root parser, enabling
- structured subcommands such as `run`, `run-all`, `preview`, etc.
-
- Args:
- parser (ArgumentParser): The root parser to attach the subparsers to.
- title (str): Title used in help output to group subcommands.
- description (str | None): Optional text describing the group of subcommands.
-
- Returns:
- _SubParsersAction: The subparsers object that can be used to add new CLI subcommands.
-
- Raises:
- TypeError: If `parser` is not an instance of `ArgumentParser`.
-
- Example:
- ```python
- >>> parser = get_root_parser()
- >>> subparsers = get_subparsers(parser, title="Available Commands")
- >>> subparsers.add_parser("run", help="Run a Falyx command")
- ```
- """
- if not isinstance(parser, ArgumentParser):
- raise TypeError("parser must be an instance of ArgumentParser")
- subparsers = parser.add_subparsers(
- title=title,
- description=description,
- dest="command",
- )
- return subparsers
-
-
-def get_arg_parsers(
- prog: str | None = "falyx",
- usage: str | None = None,
- description: str | None = "Falyx CLI - Run structured async command workflows.",
- epilog: (
- str | None
- ) = "Tip: Use 'falyx preview [COMMAND]' to preview any command from the CLI.",
- parents: Sequence[ArgumentParser] | None = None,
- prefix_chars: str = "-",
- fromfile_prefix_chars: str | None = None,
- argument_default: Any = None,
- conflict_handler: str = "error",
- add_help: bool = True,
- allow_abbrev: bool = True,
- exit_on_error: bool = True,
- commands: dict[str, Command] | None = None,
- root_parser: ArgumentParser | None = None,
- subparsers: _SubParsersAction | None = None,
-) -> FalyxParsers:
- """
- Create and return the full suite of argument parsers used by the Falyx CLI.
-
- This function builds the root parser and all subcommand parsers used for structured
- CLI workflows in Falyx. It supports standard subcommands including `run`, `run-all`,
- `preview`, `help`, and `version`, and integrates with registered `Command` objects
- to populate dynamic help and usage documentation.
-
- Args:
- prog (str | None): Program name to display in help and usage messages.
- usage (str | None): Optional usage message to override the default.
- description (str | None): Description for the CLI root parser.
- epilog (str | None): Epilog message shown after the help text.
- parents (Sequence[ArgumentParser] | None): Optional parent parsers.
- prefix_chars (str): Characters that prefix optional arguments.
- fromfile_prefix_chars (str | None): Prefix character for reading args from file.
- argument_default (Any): Default value for arguments if not specified.
- conflict_handler (str): Strategy for resolving conflicting arguments.
- add_help (bool): Whether to add the `-h/--help` option to the root parser.
- allow_abbrev (bool): Whether to allow abbreviated long options.
- exit_on_error (bool): Whether the parser exits on error or raises.
- commands (dict[str, Command] | None): Optional dictionary of registered commands
- to populate help and subcommand descriptions dynamically.
- root_parser (ArgumentParser | None): Custom root parser to use instead of building one.
- subparsers (_SubParsersAction | None): Optional existing subparser object to extend.
-
- Returns:
- FalyxParsers: A structured container of all parsers, including `run`, `run-all`,
- `preview`, `help`, `version`, and the root parser.
-
- Raises:
- TypeError: If `root_parser` is not an instance of ArgumentParser or
- `subparsers` is not an instance of _SubParsersAction.
-
- Example:
- ```python
- >>> parsers = get_arg_parsers(commands=my_command_dict)
- >>> args = parsers.root.parse_args()
- ```
-
- Notes:
- - This function integrates dynamic command usage and descriptions if the
- `commands` argument is provided.
- - The `run` parser supports additional options for retry logic and confirmation
- prompts.
- - The `run-all` parser executes all commands matching a tag.
- - Use `falyx run ?[COMMAND]` from the CLI to preview a command.
- """
- if epilog is None:
- epilog = f"Tip: Use '{prog} help' to show available commands."
- if root_parser is None:
- parser = get_root_parser(
- prog=prog,
- usage=usage,
- description=description,
- epilog=epilog,
- parents=parents,
- prefix_chars=prefix_chars,
- fromfile_prefix_chars=fromfile_prefix_chars,
- argument_default=argument_default,
- conflict_handler=conflict_handler,
- add_help=add_help,
- allow_abbrev=allow_abbrev,
- exit_on_error=exit_on_error,
- )
- else:
- if not isinstance(root_parser, ArgumentParser):
- raise TypeError("root_parser must be an instance of ArgumentParser")
- parser = root_parser
-
- if subparsers is None:
- if prog == "falyx":
- subparsers = get_subparsers(
- parser,
- title="Falyx Commands",
- description="Available commands for the Falyx CLI.",
- )
- else:
- subparsers = get_subparsers(parser, title="subcommands", description=None)
- if not isinstance(subparsers, _SubParsersAction):
- raise TypeError("subparsers must be an instance of _SubParsersAction")
-
- run_description = ["Run a command by its key or alias.\n"]
- run_description.append("commands:")
- if isinstance(commands, dict):
- for command in commands.values():
- run_description.append(command.usage)
- command_description = command.help_text or command.description
- run_description.append(f"{' '*24}{command_description}")
- run_epilog = (
- f"Tip: Use '{prog} preview [COMMAND]' to preview commands by their key or alias."
- )
- run_parser = subparsers.add_parser(
- "run",
- help="Run a specific command",
- description="\n".join(run_description),
- epilog=run_epilog,
- formatter_class=RawDescriptionHelpFormatter,
- )
- run_parser.add_argument(
- "name", help="Run a command by its key or alias", metavar="COMMAND"
- )
- run_parser.add_argument(
- "--summary",
- action="store_true",
- help="Print an execution summary after command completes",
- )
- run_parser.add_argument(
- "--retries", type=int, help="Number of retries on failure", default=0
- )
- run_parser.add_argument(
- "--retry-delay",
- type=float,
- help="Initial delay between retries in (seconds)",
- default=0,
- )
- run_parser.add_argument(
- "--retry-backoff", type=float, help="Backoff factor for retries", default=0
- )
- run_group = run_parser.add_mutually_exclusive_group(required=False)
- run_group.add_argument(
- "-c",
- "--confirm",
- dest="force_confirm",
- action="store_true",
- help="Force confirmation prompts",
- )
- run_group.add_argument(
- "-s",
- "--skip-confirm",
- dest="skip_confirm",
- action="store_true",
- help="Skip confirmation prompts",
- )
-
- run_parser.add_argument(
- "command_args",
- nargs=REMAINDER,
- help="Arguments to pass to the command (if applicable)",
- metavar="ARGS",
- )
-
- run_all_parser = subparsers.add_parser(
- "run-all", help="Run all commands with a given tag"
- )
- run_all_parser.add_argument("-t", "--tag", required=True, help="Tag to match")
- run_all_parser.add_argument(
- "--summary",
- action="store_true",
- help="Print a summary after all tagged commands run",
- )
- run_all_parser.add_argument(
- "--retries", type=int, help="Number of retries on failure", default=0
- )
- run_all_parser.add_argument(
- "--retry-delay",
- type=float,
- help="Initial delay between retries in (seconds)",
- default=0,
- )
- run_all_parser.add_argument(
- "--retry-backoff", type=float, help="Backoff factor for retries", default=0
- )
- run_all_group = run_all_parser.add_mutually_exclusive_group(required=False)
- run_all_group.add_argument(
- "-c",
- "--confirm",
- dest="force_confirm",
- action="store_true",
- help="Force confirmation prompts",
- )
- run_all_group.add_argument(
- "-s",
- "--skip-confirm",
- dest="skip_confirm",
- action="store_true",
- help="Skip confirmation prompts",
- )
-
- preview_parser = subparsers.add_parser(
- "preview", help="Preview a command without running it"
- )
- preview_parser.add_argument("name", help="Key, alias, or description of the command")
-
- help_parser = subparsers.add_parser("help", help="List all available commands")
-
- help_parser.add_argument(
- "-k",
- "--key",
- help="Show help for a specific command by its key or alias",
- default=None,
- )
-
- help_parser.add_argument(
- "-T",
- "--tldr",
- action="store_true",
- help="Show a simplified TLDR examples of a command if available",
- )
-
- help_parser.add_argument(
- "-t", "--tag", help="Filter commands by tag (case-insensitive)", default=None
- )
-
- version_parser = subparsers.add_parser("version", help=f"Show {prog} version")
-
- return FalyxParsers(
- root=parser,
- subparsers=subparsers,
- run=run_parser,
- run_all=run_all_parser,
- preview=preview_parser,
- help=help_parser,
- version=version_parser,
- )
diff --git a/falyx/parser/signature.py b/falyx/parser/signature.py
index da1f28a..74f1a47 100644
--- a/falyx/parser/signature.py
+++ b/falyx/parser/signature.py
@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Provides utilities for introspecting Python callables and extracting argument
+"""Provides utilities for introspecting Python callables and extracting argument
metadata compatible with Falyx's `CommandArgumentParser`.
This module is primarily used to auto-generate command argument definitions from
@@ -20,8 +19,7 @@ def infer_args_from_func(
func: Callable[[Any], Any] | None,
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
- """
- Infer CLI-style argument definitions from a function signature.
+ """Infer CLI-style argument definitions from a function signature.
This utility inspects the parameters of a function and returns a list of dictionaries,
each of which can be passed to `CommandArgumentParser.add_argument()`.
diff --git a/falyx/parser/utils.py b/falyx/parser/utils.py
index 71396e6..627114d 100644
--- a/falyx/parser/utils.py
+++ b/falyx/parser/utils.py
@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Contains value coercion and signature comparison utilities for Falyx argument parsing.
+"""Contains value coercion and signature comparison utilities for Falyx argument parsing.
This module provides type coercion functions for converting string input into expected
Python types, including `Enum`, `bool`, `datetime`, and `Literal`. It also supports
@@ -13,6 +12,7 @@ Functions:
- same_argument_definitions: Check if multiple callables share the same argument structure.
"""
import types
+from collections.abc import Callable
from datetime import datetime
from enum import EnumMeta
from typing import Any, Literal, Union, get_args, get_origin
@@ -24,9 +24,18 @@ 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.
+ """Convert a string to a boolean.
Accepts various truthy and falsy representations such as 'true', 'yes', '0', 'off', etc.
@@ -47,8 +56,7 @@ def coerce_bool(value: str) -> bool:
def coerce_enum(value: Any, enum_type: EnumMeta) -> Any:
- """
- Convert a raw value or string to an Enum instance.
+ """Convert a raw value or string to an Enum instance.
Tries to resolve by name, value, or coerced base type.
@@ -80,15 +88,14 @@ def coerce_enum(value: Any, enum_type: EnumMeta) -> Any:
raise ValueError(f"'{value}' should be one of {{{', '.join(values)}}}") from None
-def coerce_value(value: str, target_type: type) -> Any:
- """
- Attempt to convert a string to the given target type.
+def coerce_value(value: str, target_type: Callable[[Any], Any]) -> Any:
+ """Attempt to convert a string to the given target type.
Handles complex typing constructs such as Union, Literal, Enum, and datetime.
Args:
value (str): The input string to convert.
- target_type (type): The desired type.
+ target_type (Callable[[Any], Any]): The desired type.
Returns:
Any: The coerced value.
@@ -133,8 +140,7 @@ def same_argument_definitions(
actions: list[Any],
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
) -> list[dict[str, Any]] | None:
- """
- Determine if multiple callables resolve to the same argument definitions.
+ """Determine if multiple callables resolve to the same argument definitions.
This is used to infer whether actions in an ActionGroup or ProcessPool can share
a unified argument parser.
diff --git a/falyx/prompt_utils.py b/falyx/prompt_utils.py
index 1138d47..33d2ecc 100644
--- a/falyx/prompt_utils.py
+++ b/falyx/prompt_utils.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Utilities for user interaction prompts in the Falyx CLI framework.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Utilities for user interaction prompts in the Falyx CLI framework.
Provides asynchronous confirmation dialogs and helper logic to determine
whether a user should be prompted based on command-line options.
@@ -9,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 (
@@ -25,23 +26,62 @@ 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 = "cli_args",
-):
- """
- Determine whether to prompt the user for confirmation based on command
- and global options.
- """
- never_prompt = options.get("never_prompt", False, namespace)
- force_confirm = options.get("force_confirm", False, namespace)
- skip_confirm = options.get("skip_confirm", False, namespace)
+ action_never_prompt: bool | None = None,
+ namespace: str = "root",
+ override_namespace: str = "execution",
+) -> bool:
+ """Determine whether to prompt the user for confirmation.
- if never_prompt or skip_confirm:
+ Checks the `confirm` flag and consults the `OptionsManager` for any relevant
+ flags that may override the need for confirmation, such as `--never-prompt`,
+ `--force-confirm`, or `--skip-confirm`. The `override_namespace` is checked
+ first for any explicit overrides, followed by the main `namespace` for defaults.
+
+ 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 secondary namespace to check for options (default: "root").
+ override_namespace (str): The primary namespace for overrides (default: "execution").
+
+ Returns:
+ bool: True if the user should be prompted, False if confirmation can be bypassed.
+ """
+ if action_never_prompt is True:
return False
+ skip_confirm = options.get("skip_confirm", None, override_namespace)
+ if skip_confirm:
+ return False
+
+ never_prompt = options.get("never_prompt", None, override_namespace)
+ if never_prompt is None:
+ never_prompt = options.get("never_prompt", False, namespace)
+
+ if never_prompt:
+ return False
+
+ force_confirm = options.get("force_confirm", None, override_namespace)
+ if force_confirm is None:
+ force_confirm = options.get("force_confirm", False, namespace)
+
return confirm or force_confirm
@@ -62,9 +102,16 @@ async def confirm_async(
def rich_text_to_prompt_text(text: Text | str | StyleAndTextTuples) -> StyleAndTextTuples:
- """
- Convert a Rich Text object to a list of (style, text) tuples
- compatible with prompt_toolkit.
+ """Convert a Rich Text object to prompt_toolkit formatted text.
+
+ This function takes a Rich `Text` object (or a string or already formatted text)
+ and converts it in to a list of (style, text) tuples compatible with prompt_toolkit.
+
+ Args:
+ text (Text | str | StyleAndTextTuples): The input text to convert.
+
+ Returns:
+ StyleAndTextTuples: A list of (style, text) tuples for prompt_toolkit.
"""
if isinstance(text, list):
if all(isinstance(pair, tuple) and len(pair) == 2 for pair in text):
diff --git a/falyx/protocols.py b/falyx/protocols.py
index d308555..80b2b93 100644
--- a/falyx/protocols.py
+++ b/falyx/protocols.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines structural protocols for advanced Falyx features.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Defines structural protocols for advanced Falyx features.
These runtime-checkable `Protocol` classes specify the expected interfaces for:
- Factories that asynchronously return actions
@@ -29,4 +28,6 @@ class ActionFactoryProtocol(Protocol):
@runtime_checkable
class ArgParserProtocol(Protocol):
- def __call__(self, args: list[str]) -> tuple[tuple, dict]: ...
+ def __call__(
+ self, args: list[str]
+ ) -> tuple[tuple, dict[str, Any], dict[str, Any]]: ...
diff --git a/falyx/retry.py b/falyx/retry.py
index e87cc7e..19eff29 100644
--- a/falyx/retry.py
+++ b/falyx/retry.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Implements retry logic for Falyx Actions using configurable retry policies.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Implements retry logic for Falyx Actions using configurable retry policies.
This module defines:
- `RetryPolicy`: A configurable model controlling retry behavior (delay, backoff, jitter).
@@ -30,8 +29,7 @@ from falyx.logger import logger
class RetryPolicy(BaseModel):
- """
- Defines a retry strategy for Falyx `Action` objects.
+ """Defines a retry strategy for Falyx `Action` objects.
This model controls whether an action should be retried on failure, and how:
- `max_retries`: Maximum number of retry attempts.
@@ -60,23 +58,16 @@ class RetryPolicy(BaseModel):
enabled: bool = False
def enable_policy(self) -> None:
- """
- Enable the retry policy.
- :return: None
- """
+ """Enable the retry policy."""
self.enabled = True
def is_active(self) -> bool:
- """
- Check if the retry policy is active.
- :return: True if the retry policy is active, False otherwise.
- """
+ """Check if the retry policy is active."""
return self.max_retries > 0 and self.enabled
class RetryHandler:
- """
- Executes retry logic for Falyx actions using a provided `RetryPolicy`.
+ """Executes retry logic for Falyx actions using a provided `RetryPolicy`.
This class is intended to be registered as an `on_error` hook. It will
re-attempt the failed `Action`'s `action` method using the args/kwargs from
diff --git a/falyx/retry_utils.py b/falyx/retry_utils.py
index 14eefbc..51ec27c 100644
--- a/falyx/retry_utils.py
+++ b/falyx/retry_utils.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Utilities for enabling retry behavior across Falyx actions.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Utilities for enabling retry behavior across Falyx actions.
This module provides a helper to recursively apply a `RetryPolicy` to an action and its
nested children (e.g. `ChainedAction`, `ActionGroup`), and register the appropriate
diff --git a/falyx/routing.py b/falyx/routing.py
new file mode 100644
index 0000000..42f2eb1
--- /dev/null
+++ b/falyx/routing.py
@@ -0,0 +1,101 @@
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Routing result models for the Falyx CLI framework.
+
+This module defines the core types used to describe the outcome of namespace
+routing in a `Falyx` application.
+
+It provides:
+
+- `RouteKind`, an enum describing the kind of routed target that was reached,
+ such as a leaf command, namespace help, namespace TLDR, namespace menu, or
+ an unknown entry.
+- `RouteResult`, a structured value object that captures the resolved routing
+ state, including the active namespace, invocation context, optional leaf
+ command, remaining argv for command-local parsing, and any suggestions for
+ unresolved input.
+
+These types sit at the boundary between routing and execution. They do not
+perform routing themselves. Instead, they are produced by Falyx routing logic
+and then consumed by help rendering, completion, validation, preview, and
+command dispatch flows.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import TYPE_CHECKING, Any
+
+from falyx.context import InvocationContext
+from falyx.namespace import FalyxNamespace
+
+if TYPE_CHECKING:
+ from falyx.command import Command
+ from falyx.falyx import Falyx
+
+
+class RouteKind(Enum):
+ """Enumerates the possible outcomes of Falyx namespace routing.
+
+ `RouteKind` identifies what the routing layer resolved the current input
+ to, allowing downstream code to decide whether it should execute a command,
+ render namespace help, show TLDR output, display a namespace menu, or
+ surface an unknown-entry message.
+
+ Attributes:
+ COMMAND: Routing reached a leaf command that may be parsed and executed.
+ NAMESPACE_MENU: Routing stopped at a namespace menu target.
+ NAMESPACE_HELP: Routing resolved to namespace help output.
+ NAMESPACE_TLDR: Routing resolved to namespace TLDR output.
+ UNKNOWN: Routing failed to resolve the requested entry.
+ """
+
+ COMMAND = "command"
+ NAMESPACE_MENU = "namespace_menu"
+ NAMESPACE_HELP = "namespace_help"
+ NAMESPACE_TLDR = "namespace_tldr"
+ UNKNOWN = "unknown"
+
+
+@dataclass(slots=True)
+class RouteResult:
+ """Represents the resolved output of a Falyx routing operation.
+
+ `RouteResult` captures the full state needed after namespace resolution
+ completes and before command execution or help rendering begins. It records
+ what kind of target was reached, where routing ended, the invocation path
+ used to reach it, and any leaf-command metadata needed for downstream
+ parsing.
+
+ This model is used by Falyx execution, help, preview, completion, and
+ validation flows to make routing decisions explicit and easy to inspect.
+
+ Attributes:
+ kind: The type of routed result that was resolved.
+ namespace: The `Falyx` namespace where routing ended.
+ context: Invocation context describing the routed path and current mode.
+ command: Resolved leaf command, if routing ended at a command.
+ namespace_entry: Resolved namespace entry, if the route corresponds to a
+ 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.
+ root_overrides: Root-level option overrides to apply for this route.
+ namespace_overrides: Namespace-level option overrides to apply for this route.
+ """
+
+ kind: RouteKind
+ namespace: "Falyx"
+ context: InvocationContext
+ 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
+ root_defaults: dict[str, Any] = field(default_factory=dict)
+ root_overrides: dict[str, Any] = field(default_factory=dict)
+ namespace_defaults: dict[str, Any] = field(default_factory=dict)
+ namespace_overrides: dict[str, Any] = field(default_factory=dict)
diff --git a/falyx/selection.py b/falyx/selection.py
index 49a39d3..cbfb942 100644
--- a/falyx/selection.py
+++ b/falyx/selection.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Provides interactive selection utilities for Falyx CLI actions.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Provides interactive selection utilities for Falyx CLI actions.
This module defines `SelectionOption` objects, selection maps, and rich-powered
rendering functions to build interactive selection prompts using `prompt_toolkit`.
@@ -12,6 +11,8 @@ It supports:
Used by `SelectionAction` and other prompt-driven workflows within Falyx.
"""
+from __future__ import annotations
+
from dataclasses import dataclass
from typing import Any, Callable, KeysView, Sequence
@@ -21,7 +22,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
@@ -44,11 +45,17 @@ class SelectionOption:
key = escape(f"[{key}]")
return f"[{OneColors.WHITE}]{key}[/] [{self.style}]{self.description}[/]"
+ def copy(self) -> SelectionOption:
+ """Create a copy of the SelectionOption."""
+ return SelectionOption(
+ description=self.description,
+ value=self.value,
+ style=self.style,
+ )
+
class SelectionOptionMap(CaseInsensitiveDict):
- """
- Manages selection options including validation and reserved key protection.
- """
+ """Manages selection options including validation and reserved key protection."""
RESERVED_KEYS: set[str] = set()
@@ -100,6 +107,13 @@ class SelectionOptionMap(CaseInsensitiveDict):
continue
yield k, v
+ def copy(self) -> SelectionOptionMap:
+ """Create a copy of the SelectionOptionMap."""
+ new_map = SelectionOptionMap(allow_reserved=self.allow_reserved)
+ for key, option in self.items():
+ new_map[key] = option.copy()
+ return new_map
+
def render_table_base(
title: str,
@@ -118,6 +132,7 @@ def render_table_base(
highlight: bool = True,
column_names: Sequence[str] | None = None,
) -> Table:
+ """Render the base table for selection prompts."""
table = Table(
title=title,
caption=caption,
@@ -288,24 +303,38 @@ async def prompt_for_index(
allow_duplicates: bool = False,
cancel_key: str = "",
) -> int | list[int]:
+ """Prompt the user to select an index from a table of options. Return the selected index."""
prompt_session = prompt_session or PromptSession()
if show_table:
console.print(table, justify="center")
- selection = await prompt_session.prompt_async(
- message=rich_text_to_prompt_text(prompt_message),
- validator=MultiIndexValidator(
- min_index,
- max_index,
- number_selections,
- separator,
- allow_duplicates,
- cancel_key,
- ),
- default=default_selection,
+ number_selections_str = (
+ f"{number_selections} " if isinstance(number_selections, int) else ""
)
+ plural = "s" if number_selections != 1 else ""
+ placeholder = (
+ f"Enter {number_selections_str}selection{plural} separated by '{separator}'"
+ if number_selections != 1
+ else "Enter selection"
+ )
+
+ with prompt_session_context(prompt_session) as session:
+ selection = await session.prompt_async(
+ message=rich_text_to_prompt_text(prompt_message),
+ validator=MultiIndexValidator(
+ min_index,
+ max_index,
+ number_selections,
+ separator,
+ allow_duplicates,
+ cancel_key,
+ ),
+ default=default_selection,
+ placeholder=placeholder,
+ )
+
if selection.strip() == cancel_key:
return int(cancel_key)
if isinstance(number_selections, int) and number_selections == 1:
@@ -332,14 +361,27 @@ async def prompt_for_selection(
if show_table:
console.print(table, justify="center")
- selected = await prompt_session.prompt_async(
- message=rich_text_to_prompt_text(prompt_message),
- validator=MultiKeyValidator(
- keys, number_selections, separator, allow_duplicates, cancel_key
- ),
- default=default_selection,
+ number_selections_str = (
+ f"{number_selections} " if isinstance(number_selections, int) else ""
)
+ plural = "s" if number_selections != 1 else ""
+ placeholder = (
+ f"Enter {number_selections_str}selection{plural} separated by '{separator}'"
+ if number_selections != 1
+ else "Enter selection"
+ )
+
+ with prompt_session_context(prompt_session) as session:
+ selected = await session.prompt_async(
+ message=rich_text_to_prompt_text(prompt_message),
+ validator=MultiKeyValidator(
+ keys, number_selections, separator, allow_duplicates, cancel_key
+ ),
+ default=default_selection,
+ placeholder=placeholder,
+ )
+
if selected.strip() == cancel_key:
return cancel_key
if isinstance(number_selections, int) and number_selections == 1:
diff --git a/falyx/signals.py b/falyx/signals.py
index 191c61d..b113485 100644
--- a/falyx/signals.py
+++ b/falyx/signals.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Defines flow control signals used internally by the Falyx CLI framework.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Defines flow control signals used internally by the Falyx CLI framework.
These signals are raised to interrupt or redirect CLI execution flow
(e.g., returning to a menu, quitting, or displaying help) without
diff --git a/falyx/spinner_manager.py b/falyx/spinner_manager.py
index d80465f..fa01b1c 100644
--- a/falyx/spinner_manager.py
+++ b/falyx/spinner_manager.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Centralized spinner rendering for Falyx CLI.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Centralized spinner rendering for Falyx CLI.
This module provides the `SpinnerManager` class, which manages a collection of
Rich spinners that can be displayed concurrently during long-running tasks.
@@ -55,8 +54,7 @@ from falyx.themes import OneColors
class SpinnerData:
- """
- Holds the configuration and Rich spinner object for a single task.
+ """Holds the configuration and Rich spinner object for a single task.
This class is a lightweight container for spinner metadata, storing the
message text, spinner type, style, and speed. It also initializes the
@@ -92,8 +90,7 @@ class SpinnerData:
class SpinnerManager:
- """
- Manages multiple Rich spinners and handles their terminal rendering.
+ """Manages multiple Rich spinners and handles their terminal rendering.
SpinnerManager maintains a registry of active spinners and a single
Rich `Live` display loop to render them. When the first spinner is added,
diff --git a/falyx/tagged_table.py b/falyx/tagged_table.py
index 8026cae..62b0dfc 100644
--- a/falyx/tagged_table.py
+++ b/falyx/tagged_table.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Generates a Rich table view of Falyx commands grouped by their tags.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Generates a Rich table view of Falyx commands grouped by their tags.
This module defines a utility function for rendering a custom CLI command
table that organizes commands into groups based on their first tag. It is
@@ -25,19 +24,19 @@ def build_tagged_table(flx: Falyx) -> Table:
# Group commands by first tag
grouped: dict[str, list[Command]] = defaultdict(list)
- for cmd in flx.commands.values():
- first_tag = cmd.tags[0] if cmd.tags else "Other"
- grouped[first_tag.capitalize()].append(cmd)
+ for command in flx.commands.values():
+ first_tag = command.tags[0] if command.tags else "Other"
+ grouped[first_tag.capitalize()].append(command)
# Add grouped commands to table
for group_name, commands in grouped.items():
table.add_row(f"[bold underline]{group_name} Commands[/]")
- for cmd in commands:
- table.add_row(f"[{cmd.key}] [{cmd.style}]{cmd.description}")
+ for command in commands:
+ table.add_row(f"[{command.key}] [{command.style}]{command.description}")
table.add_row("")
# Add bottom row
- for row in flx.get_bottom_row():
+ for row in flx._get_bottom_row():
table.add_row(row)
return table
diff --git a/falyx/themes/__init__.py b/falyx/themes/__init__.py
index 651f175..2073e95 100644
--- a/falyx/themes/__init__.py
+++ b/falyx/themes/__init__.py
@@ -1,7 +1,6 @@
-"""
-Falyx CLI Framework
+"""Falyx CLI Framework
-Copyright (c) 2025 rtj.dev LLC.
+Copyright (c) 2026 rtj.dev LLC.
Licensed under the MIT License. See LICENSE file for details.
"""
diff --git a/falyx/themes/colors.py b/falyx/themes/colors.py
index 7b5d0de..d4afaf0 100644
--- a/falyx/themes/colors.py
+++ b/falyx/themes/colors.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-A Python module that integrates the Nord color palette with the Rich library.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""A Python module that integrates the Nord color palette with the Rich library.
It defines a metaclass-based NordColors class allowing dynamic attribute lookups
(e.g., NORD12bu -> "#D08770 bold underline") and provides a comprehensive Nord-based
Theme that customizes Rich's default styles.
@@ -26,8 +25,7 @@ from rich.theme import Theme
class ColorsMeta(type):
- """
- A metaclass that catches attribute lookups like `NORD12buidrs` or `ORANGE_b` and returns
+ """A metaclass that catches attribute lookups like `NORD12buidrs` or `ORANGE_b` and returns
a string combining the base color + bold/italic/underline/dim/reverse/strike flags.
The color values are required to be uppercase with optional underscores and digits,
@@ -152,8 +150,7 @@ class OneColors(metaclass=ColorsMeta):
class NordColors(metaclass=ColorsMeta):
- """
- Defines the Nord color palette as class attributes.
+ """Defines the Nord color palette as class attributes.
Each color is labeled by its canonical Nord name (NORD0-NORD15)
and also has useful aliases grouped by theme:
@@ -212,8 +209,7 @@ class NordColors(metaclass=ColorsMeta):
@classmethod
def as_dict(cls):
- """
- Returns a dictionary mapping every NORD* attribute
+ """Returns a dictionary mapping every NORD* attribute
(e.g. 'NORD0') to its hex code.
"""
return {
@@ -224,8 +220,7 @@ class NordColors(metaclass=ColorsMeta):
@classmethod
def aliases(cls):
- """
- Returns a dictionary of *all* other aliases
+ """Returns a dictionary of *all* other aliases
(Polar Night, Snow Storm, Frost, Aurora).
"""
skip_prefixes = ("NORD", "__")
@@ -462,9 +457,7 @@ NORD_THEME_STYLES: dict[str, Style] = {
def get_nord_theme() -> Theme:
- """
- Returns a Rich Theme for the Nord color palette.
- """
+ """Returns a Rich Theme for the Nord color palette."""
return Theme(NORD_THEME_STYLES)
diff --git a/falyx/utils.py b/falyx/utils.py
index 9766e61..7f121de 100644
--- a/falyx/utils.py
+++ b/falyx/utils.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-General-purpose utilities and helpers for the Falyx CLI framework.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""General-purpose utilities and helpers for the Falyx CLI framework.
This module includes asynchronous wrappers, logging setup, formatting utilities,
and small type-safe enhancements such as `CaseInsensitiveDict` and coroutine enforcement.
@@ -92,6 +91,9 @@ class CaseInsensitiveDict(dict):
def __getitem__(self, key):
return super().__getitem__(self._normalize_key(key))
+ def __delitem__(self, key):
+ super().__delitem__(self._normalize_key(key))
+
def __contains__(self, key):
return super().__contains__(self._normalize_key(key))
@@ -130,8 +132,7 @@ def setup_logging(
file_log_level: int = logging.DEBUG,
console_log_level: int = logging.WARNING,
):
- """
- Configure logging for Falyx with support for both CLI-friendly and structured
+ """Configure logging for Falyx with support for both CLI-friendly and structured
JSON output.
This function sets up separate logging handlers for console and file output,
diff --git a/falyx/validators.py b/falyx/validators.py
index 096693e..4125bba 100644
--- a/falyx/validators.py
+++ b/falyx/validators.py
@@ -1,6 +1,5 @@
-# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
-"""
-Input validators for use with Prompt Toolkit and interactive Falyx CLI workflows.
+# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
+"""Input validators for use with Prompt Toolkit and interactive Falyx CLI workflows.
This module defines reusable `Validator` instances and subclasses that enforce valid
user input during prompts—especially for selection actions, confirmations, and
@@ -22,6 +21,8 @@ from typing import TYPE_CHECKING, KeysView, Sequence
from prompt_toolkit.validation import ValidationError, Validator
+from falyx.routing import RouteKind
+
if TYPE_CHECKING:
from falyx.falyx import Falyx
@@ -48,10 +49,33 @@ class CommandValidator(Validator):
message=self.error_message,
cursor_position=len(text),
)
- is_preview, choice, _, __ = await self.falyx.get_command(text, from_validate=True)
- if is_preview:
+ route, _, __, ___ = await self.falyx.prepare_route(text, from_validate=True)
+ if not route:
+ raise ValidationError(
+ message=self.error_message,
+ cursor_position=len(text),
+ )
+ 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 not choice:
+ if route.kind in {
+ RouteKind.NAMESPACE_MENU,
+ RouteKind.NAMESPACE_HELP,
+ RouteKind.NAMESPACE_TLDR,
+ }:
+ return None
+ if route.kind is RouteKind.COMMAND and route.command is None:
+ raise ValidationError(
+ message=self.error_message,
+ cursor_position=len(text),
+ )
+ elif route.kind is RouteKind.COMMAND:
+ return None
+ if route.kind is RouteKind.UNKNOWN:
raise ValidationError(
message=self.error_message,
cursor_position=len(text),
@@ -132,6 +156,8 @@ def word_validator(word: str) -> Validator:
class MultiIndexValidator(Validator):
+ """Validator for multiple index selections (e.g. '1,2,3')."""
+
def __init__(
self,
minimum: int,
@@ -182,6 +208,8 @@ class MultiIndexValidator(Validator):
class MultiKeyValidator(Validator):
+ """Validator for multiple key selections (e.g. 'A,B,C')."""
+
def __init__(
self,
keys: Sequence[str] | KeysView[str],
diff --git a/falyx/version.py b/falyx/version.py
index ea23fb5..d3ec452 100644
--- a/falyx/version.py
+++ b/falyx/version.py
@@ -1 +1 @@
-__version__ = "0.1.87"
+__version__ = "0.2.0"
diff --git a/pyproject.toml b/pyproject.toml
index e8c5504..efe5da9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "falyx"
-version = "0.1.87"
+version = "0.2.0"
description = "Reliable and introspectable async CLI action framework."
authors = ["Roland Thomas Jr "]
license = "MIT"
diff --git a/tests/test_action_basic.py b/tests/test_actions/test_action_basic.py
similarity index 100%
rename from tests/test_action_basic.py
rename to tests/test_actions/test_action_basic.py
diff --git a/tests/test_action_fallback.py b/tests/test_actions/test_action_fallback.py
similarity index 100%
rename from tests/test_action_fallback.py
rename to tests/test_actions/test_action_fallback.py
diff --git a/tests/test_action_hooks.py b/tests/test_actions/test_action_hooks.py
similarity index 100%
rename from tests/test_action_hooks.py
rename to tests/test_actions/test_action_hooks.py
diff --git a/tests/test_action_process.py b/tests/test_actions/test_action_process.py
similarity index 100%
rename from tests/test_action_process.py
rename to tests/test_actions/test_action_process.py
diff --git a/tests/test_action_retries.py b/tests/test_actions/test_action_retries.py
similarity index 100%
rename from tests/test_action_retries.py
rename to tests/test_actions/test_action_retries.py
diff --git a/tests/test_actions.py b/tests/test_actions/test_actions.py
similarity index 100%
rename from tests/test_actions.py
rename to tests/test_actions/test_actions.py
diff --git a/tests/test_chained_action_empty.py b/tests/test_actions/test_chained_action_empty.py
similarity index 100%
rename from tests/test_chained_action_empty.py
rename to tests/test_actions/test_chained_action_empty.py
diff --git a/tests/test_actions/test_clone.py b/tests/test_actions/test_clone.py
new file mode 100644
index 0000000..2ce7c8c
--- /dev/null
+++ b/tests/test_actions/test_clone.py
@@ -0,0 +1,334 @@
+import pytest
+
+from falyx.action.action import Action
+from falyx.action.action_group import ActionGroup
+from falyx.action.chained_action import ChainedAction
+from falyx.action.http_action import HTTPAction
+from falyx.action.menu_action import MenuAction
+from falyx.action.process_action import ProcessAction
+from falyx.hook_manager import HookType
+from falyx.menu import MenuOption, MenuOptionMap
+from falyx.retry import RetryHandler, RetryPolicy
+
+
+def _retry_hooks(action) -> list:
+ return [
+ hook
+ for hook in action.hooks._hooks[HookType.ON_ERROR]
+ if isinstance(getattr(hook, "__self__", None), RetryHandler)
+ ]
+
+
+def _non_retry_error_hooks(action) -> list:
+ return [
+ hook
+ for hook in action.hooks._hooks[HookType.ON_ERROR]
+ if not isinstance(getattr(hook, "__self__", None), RetryHandler)
+ ]
+
+
+def _before_hooks(action) -> list:
+ return list(action.hooks._hooks[HookType.BEFORE])
+
+
+def test_action_group_clone_recursively_isolates_nested_action_graph():
+ nested_chain = ChainedAction(
+ name="nested-chain",
+ actions=[
+ Action("step-two", lambda: "two"),
+ Action("step-three", lambda: "three"),
+ ],
+ )
+ original = ActionGroup(
+ name="group",
+ actions=[
+ Action("step-one", lambda: "one"),
+ nested_chain,
+ ],
+ )
+
+ cloned = original.clone()
+
+ assert cloned is not original
+ assert cloned.actions is not original.actions
+ assert len(cloned.actions) == len(original.actions)
+
+ # Top-level children are cloned.
+ assert cloned.actions[0] is not original.actions[0]
+ assert cloned.actions[1] is not original.actions[1]
+
+ # Nested action graph is also cloned.
+ assert isinstance(cloned.actions[1], ChainedAction)
+ assert cloned.actions[1].actions is not original.actions[1].actions
+ for cloned_child, original_child in zip(
+ cloned.actions[1].actions,
+ original.actions[1].actions,
+ strict=True,
+ ):
+ assert cloned_child is not original_child
+ assert cloned_child.name == original_child.name
+
+ # Mutating the clone does not mutate the original.
+ cloned.actions.append(Action("step-four", lambda: "four"))
+ assert len(cloned.actions) == 3
+ assert len(original.actions) == 2
+
+ cloned.actions[1].actions.append(Action("step-five", lambda: "five"))
+ assert len(cloned.actions[1].actions) == 3
+ assert len(original.actions[1].actions) == 2
+
+
+def test_menu_action_clone_copies_menu_option_map_and_clones_contained_actions():
+ menu_options = MenuOptionMap(disable_reserved=True)
+ menu_options["A"] = MenuOption(
+ description="Alpha",
+ action=Action("alpha-action", lambda: "alpha"),
+ )
+
+ original = MenuAction(
+ name="main-menu",
+ menu_options=menu_options,
+ title="Main Menu",
+ )
+
+ cloned = original.clone()
+
+ assert cloned is not original
+ assert cloned.menu_options is not original.menu_options
+
+ assert cloned.menu_options["A"] is not original.menu_options["A"]
+ assert cloned.menu_options["A"].description == original.menu_options["A"].description
+
+ # Contained action should also be cloned.
+ assert cloned.menu_options["A"].action is not original.menu_options["A"].action
+ assert cloned.menu_options["A"].action.name == original.menu_options["A"].action.name
+
+ # Mutating the clone should not affect the original.
+ cloned.menu_options["A"].description = "Changed"
+ assert original.menu_options["A"].description == "Alpha"
+
+ cloned.menu_options["B"] = MenuOption(
+ description="Beta",
+ action=Action("beta-action", lambda: "beta"),
+ )
+ assert "B" in cloned.menu_options
+ assert "B" not in original.menu_options
+
+
+def test_process_action_clone_does_not_reuse_runtime_only_executor_state():
+ original = ProcessAction(
+ name="proc",
+ action=lambda x: x + 1,
+ args=(1,),
+ kwargs={"y": 2},
+ )
+
+ original.executor = object()
+
+ cloned = original.clone()
+
+ assert cloned is not original
+ assert cloned.hooks is not original.hooks
+ assert cloned.args == original.args
+ assert cloned.kwargs == original.kwargs
+
+ assert cloned.executor is not original.executor
+
+
+def test_http_action_clone_preserves_retry_policy_without_duplicating_spinner_hooks():
+ retry_policy = RetryPolicy(max_retries=3, delay=1.5, backoff=2.0)
+ retry_policy.enable_policy()
+
+ original = HTTPAction(
+ name="get-users",
+ method="GET",
+ url="https://example.com/api/users",
+ headers={"Authorization": "Bearer token"},
+ params={"page": 1},
+ retry_policy=retry_policy,
+ spinner=True,
+ )
+
+ before_count = len(original.hooks._hooks[HookType.BEFORE])
+ teardown_count = len(original.hooks._hooks[HookType.ON_TEARDOWN])
+ error_count = len(original.hooks._hooks[HookType.ON_ERROR])
+
+ cloned = original.clone()
+
+ assert cloned is not original
+ assert cloned.hooks is not original.hooks
+
+ assert cloned.retry_policy is not original.retry_policy
+ assert cloned.retry_policy.enabled is original.retry_policy.enabled
+ assert cloned.retry_policy.max_retries == original.retry_policy.max_retries
+ assert cloned.retry_policy.delay == original.retry_policy.delay
+ assert cloned.retry_policy.backoff == original.retry_policy.backoff
+
+ assert len(cloned.hooks._hooks[HookType.BEFORE]) == before_count
+ assert len(cloned.hooks._hooks[HookType.ON_TEARDOWN]) == teardown_count
+ assert len(cloned.hooks._hooks[HookType.ON_ERROR]) == error_count
+
+
+@pytest.mark.asyncio
+async def test_action_clone_registers_exactly_one_retry_hook():
+ async def flaky():
+ return "ok"
+
+ policy = RetryPolicy(max_retries=2, delay=0.1, backoff=2.0)
+ policy.enable_policy()
+
+ original = Action(
+ "flaky",
+ flaky,
+ retry_policy=policy,
+ )
+
+ cloned = original.clone()
+
+ original_retry_hooks = _retry_hooks(original)
+ cloned_retry_hooks = _retry_hooks(cloned)
+
+ assert len(original_retry_hooks) == 1
+ assert len(cloned_retry_hooks) == 1
+
+ assert cloned_retry_hooks[0] is not original_retry_hooks[0]
+ assert getattr(cloned_retry_hooks[0], "__self__", None) is not getattr(
+ original_retry_hooks[0], "__self__", None
+ )
+
+
+def test_action_clone_preserves_non_retry_hooks_without_duplication():
+ calls = []
+
+ async def custom_error_hook(context):
+ calls.append(context.name)
+
+ original = Action("demo", lambda: "ok")
+ original.hooks.register(HookType.BEFORE, lambda context: None)
+ original.hooks.register(HookType.ON_ERROR, custom_error_hook)
+
+ cloned = original.clone()
+
+ assert len(_before_hooks(cloned)) == len(_before_hooks(original))
+ assert len(_non_retry_error_hooks(cloned)) == len(_non_retry_error_hooks(original))
+
+ assert cloned.hooks is not original.hooks
+
+
+def test_action_clone_copies_retry_policy_without_sharing_it():
+ policy = RetryPolicy(max_retries=2, delay=0.25, backoff=3.0)
+ policy.enable_policy()
+
+ original = Action(
+ "demo",
+ lambda: "ok",
+ retry_policy=policy,
+ )
+
+ cloned = original.clone()
+
+ assert cloned.retry_policy is not original.retry_policy
+ assert cloned.retry_policy.enabled is original.retry_policy.enabled
+ assert cloned.retry_policy.max_retries == original.retry_policy.max_retries
+ assert cloned.retry_policy.delay == original.retry_policy.delay
+ assert cloned.retry_policy.backoff == original.retry_policy.backoff
+
+ cloned.retry_policy.max_retries = 9
+ assert original.retry_policy.max_retries == 2
+
+
+@pytest.mark.asyncio
+async def test_action_clone_retry_behavior_still_works_independently():
+ state = {"original": 0, "clone": 0}
+
+ async def flaky_original():
+ if state["original"] == 0:
+ state["original"] += 1
+ raise RuntimeError("boom")
+ return "original-ok"
+
+ async def flaky_clone():
+ if state["clone"] == 0:
+ state["clone"] += 1
+ raise RuntimeError("boom")
+ return "clone-ok"
+
+ policy = RetryPolicy(max_retries=1, delay=0.0, backoff=1.0)
+ policy.enable_policy()
+
+ original = Action("orig", flaky_original, retry_policy=policy)
+ cloned = original.clone()
+
+ cloned.action = flaky_clone
+
+ original_result = await original()
+ cloned_result = await cloned()
+
+ assert original_result == "original-ok"
+ assert cloned_result == "clone-ok"
+ assert state["original"] == 1
+ assert state["clone"] == 1
+
+
+def test_http_action_clone_registers_exactly_one_retry_hook():
+ policy = RetryPolicy(max_retries=2, delay=0.1, backoff=2.0)
+ policy.enable_policy()
+
+ original = HTTPAction(
+ name="get-users",
+ method="GET",
+ url="https://example.com/api/users",
+ retry_policy=policy,
+ spinner=True,
+ )
+
+ cloned = original.clone()
+
+ original_retry_hooks = _retry_hooks(original)
+ cloned_retry_hooks = _retry_hooks(cloned)
+
+ assert len(original_retry_hooks) == 1
+ assert len(cloned_retry_hooks) == 1
+ assert cloned_retry_hooks[0] is not original_retry_hooks[0]
+
+
+def test_http_action_clone_copies_retry_policy_without_sharing_it():
+ policy = RetryPolicy(max_retries=3, delay=1.5, backoff=2.0)
+ policy.enable_policy()
+
+ original = HTTPAction(
+ name="get-users",
+ method="GET",
+ url="https://example.com/api/users",
+ retry_policy=policy,
+ )
+
+ cloned = original.clone()
+
+ assert cloned.retry_policy is not original.retry_policy
+ assert cloned.retry_policy.enabled is original.retry_policy.enabled
+ assert cloned.retry_policy.max_retries == original.retry_policy.max_retries
+ assert cloned.retry_policy.delay == original.retry_policy.delay
+ assert cloned.retry_policy.backoff == original.retry_policy.backoff
+
+
+def test_http_action_clone_does_not_duplicate_spinner_hooks():
+ policy = RetryPolicy(max_retries=1, delay=0.0, backoff=1.0)
+ policy.enable_policy()
+
+ original = HTTPAction(
+ name="get-users",
+ method="GET",
+ url="https://example.com/api/users",
+ retry_policy=policy,
+ spinner=True,
+ )
+
+ cloned = original.clone()
+
+ assert len(cloned.hooks._hooks[HookType.BEFORE]) == len(
+ original.hooks._hooks[HookType.BEFORE]
+ )
+ assert len(cloned.hooks._hooks[HookType.ON_TEARDOWN]) == len(
+ original.hooks._hooks[HookType.ON_TEARDOWN]
+ )
diff --git a/tests/test_actions/test_load_file_action.py b/tests/test_actions/test_load_file_action.py
new file mode 100644
index 0000000..c8c3fc4
--- /dev/null
+++ b/tests/test_actions/test_load_file_action.py
@@ -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
diff --git a/tests/test_actions/test_save_file_action.py b/tests/test_actions/test_save_file_action.py
new file mode 100644
index 0000000..9acda6a
--- /dev/null
+++ b/tests/test_actions/test_save_file_action.py
@@ -0,0 +1,430 @@
+from __future__ import annotations
+
+import csv
+import json
+import xml.etree.ElementTree as ET
+from pathlib import Path
+from typing import Any
+
+import pytest
+import toml
+import yaml
+from rich.tree import Tree
+
+from falyx.action.action_types import FileType
+from falyx.action.save_file_action import SaveFileAction
+from falyx.hook_manager import HookType
+
+
+class CaptureConsole:
+ def __init__(self) -> None:
+ self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
+
+ def print(self, *args: Any, **kwargs: Any) -> None:
+ self.printed.append((args, kwargs))
+
+
+def make_action(file_path: Path | str | None, **overrides: Any) -> SaveFileAction:
+ defaults: dict[str, Any] = {
+ "name": "SaveOutput",
+ "file_path": file_path,
+ }
+ defaults.update(overrides)
+ return SaveFileAction(**defaults)
+
+
+def register_lifecycle_hooks(action: SaveFileAction) -> list[tuple[HookType, Any]]:
+ calls: list[tuple[HookType, Any]] = []
+
+ def make_hook(hook_type: HookType):
+ def hook(context: Any) -> None:
+ calls.append((hook_type, context))
+
+ return hook
+
+ for hook_type in HookType:
+ action.hooks.register(hook_type, make_hook(hook_type))
+
+ return calls
+
+
+def hook_types(calls: list[tuple[HookType, Any]]) -> list[HookType]:
+ return [hook_type for hook_type, _ in calls]
+
+
+def test_init_normalizes_configuration_and_string_file_type(tmp_path: Path) -> None:
+ target = tmp_path / "output.json"
+
+ action = SaveFileAction(
+ name="SaveJson",
+ file_path=str(target),
+ file_type="json",
+ mode="a",
+ encoding="utf-8",
+ data={"name": "falyx"},
+ overwrite=False,
+ create_dirs=False,
+ inject_last_result=True,
+ inject_into="payload",
+ never_prompt=True,
+ )
+
+ assert action.name == "SaveJson"
+ assert action.file_path == target
+ assert action.file_type == FileType.JSON
+ assert action.mode == "a"
+ assert action.encoding == "utf-8"
+ assert action.data == {"name": "falyx"}
+ assert action.overwrite is False
+ assert action.create_dirs is False
+ assert action.inject_last_result is True
+ assert action.inject_into == "payload"
+ assert action.local_never_prompt is True
+ assert "SaveFileAction" in str(action)
+ assert "output.json" in str(action)
+
+
+def test_file_path_property_coerces_string_path_and_none(tmp_path: Path) -> None:
+ action = make_action(None)
+
+ assert action.file_path is None
+
+ target = tmp_path / "later.txt"
+ action.file_path = str(target)
+
+ assert action.file_path == target
+
+ action.file_path = target
+
+ assert action.file_path == target
+
+
+def test_file_path_rejects_unsupported_values(tmp_path: Path) -> None:
+ action = make_action(tmp_path / "out.txt")
+
+ with pytest.raises(TypeError, match="file_path must be a string or Path object"):
+ action.file_path = 123 # type: ignore[assignment]
+
+
+def test_get_infer_target_disables_signature_inference(tmp_path: Path) -> None:
+ action = make_action(tmp_path / "out.txt")
+
+ assert action.get_infer_target() == (None, None)
+
+
+def test_dict_to_xml_serializes_nested_dicts_lists_and_scalars(tmp_path: Path) -> None:
+ action = make_action(tmp_path / "out.xml", file_type=FileType.XML)
+ root = ET.Element("root")
+
+ action._dict_to_xml(
+ {
+ "name": "falyx",
+ "metadata": {"version": "0.2.0"},
+ "tags": ["cli", "framework"],
+ "commands": [{"name": "run"}, {"name": "help"}],
+ },
+ root,
+ )
+
+ assert root.findtext("name") == "falyx"
+ assert root.find("metadata") is not None
+ assert root.find("metadata/version") is not None
+ assert root.findtext("metadata/version") == "0.2.0"
+ assert [element.text for element in root.findall("tags")] == ["cli", "framework"]
+ assert [element.findtext("name") for element in root.findall("commands")] == [
+ "run",
+ "help",
+ ]
+
+
+@pytest.mark.asyncio
+async def test_save_file_requires_file_path_before_saving() -> None:
+ action = make_action(None, data="hello")
+
+ with pytest.raises(ValueError, match="file_path must be set"):
+ await action.save_file("hello")
+
+
+@pytest.mark.asyncio
+async def test_save_file_refuses_to_overwrite_existing_file_when_disabled(
+ tmp_path: Path,
+) -> None:
+ target = tmp_path / "existing.txt"
+ target.write_text("original", encoding="UTF-8")
+ action = make_action(target, overwrite=False)
+
+ with pytest.raises(FileExistsError, match="File already exists"):
+ await action.save_file("replacement")
+
+ assert target.read_text(encoding="UTF-8") == "original"
+
+
+@pytest.mark.asyncio
+async def test_save_file_requires_parent_directory_when_create_dirs_is_disabled(
+ tmp_path: Path,
+) -> None:
+ target = tmp_path / "missing" / "out.txt"
+ action = make_action(target, create_dirs=False)
+
+ with pytest.raises(FileNotFoundError, match="Directory does not exist"):
+ await action.save_file("hello")
+
+
+@pytest.mark.asyncio
+async def test_save_file_creates_missing_parent_directories(tmp_path: Path) -> None:
+ target = tmp_path / "nested" / "out.txt"
+ action = make_action(target, file_type=FileType.TEXT, create_dirs=True)
+
+ await action.save_file("hello")
+
+ assert target.read_text(encoding="UTF-8") == "hello"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("file_type", "filename", "data"),
+ [
+ (FileType.TEXT, "note.txt", "hello"),
+ (FileType.JSON, "data.json", {"name": "falyx", "count": 2}),
+ (FileType.YAML, "data.yaml", {"name": "falyx", "enabled": True}),
+ (FileType.TOML, "data.toml", {"name": "falyx", "count": 2}),
+ (FileType.CSV, "rows.csv", [["name", "count"], ["falyx", "2"]]),
+ (FileType.TSV, "rows.tsv", [["name", "count"], ["falyx", "2"]]),
+ (
+ FileType.XML,
+ "data.xml",
+ {
+ "name": "falyx",
+ "metadata": {"version": "0.2.0"},
+ "tags": ["cli", "framework"],
+ },
+ ),
+ ],
+)
+async def test_save_file_writes_supported_file_types(
+ tmp_path: Path,
+ file_type: FileType,
+ filename: str,
+ data: Any,
+) -> None:
+ target = tmp_path / filename
+ action = make_action(target, file_type=file_type)
+
+ await action.save_file(data)
+
+ if file_type == FileType.TEXT:
+ assert target.read_text(encoding="UTF-8") == data
+ elif file_type == FileType.JSON:
+ assert json.loads(target.read_text(encoding="UTF-8")) == data
+ elif file_type == FileType.YAML:
+ assert yaml.safe_load(target.read_text(encoding="UTF-8")) == data
+ elif file_type == FileType.TOML:
+ assert toml.loads(target.read_text(encoding="UTF-8")) == data
+ elif file_type == FileType.CSV:
+ with target.open(newline="", encoding="UTF-8") as file:
+ assert list(csv.reader(file)) == data
+ elif file_type == FileType.TSV:
+ with target.open(newline="", encoding="UTF-8") as file:
+ assert list(csv.reader(file, delimiter="\t")) == data
+ elif file_type == FileType.XML:
+ root = ET.parse(target).getroot()
+ assert root.tag == "root"
+ assert root.findtext("name") == "falyx"
+ assert root.findtext("metadata/version") == "0.2.0"
+ assert [element.text for element in root.findall("tags")] == [
+ "cli",
+ "framework",
+ ]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("file_type", [FileType.CSV, FileType.TSV])
+@pytest.mark.parametrize(
+ "data",
+ [
+ {"name": "falyx"},
+ ["name", "count"],
+ [["name", "count"], "not-a-row"],
+ ],
+)
+async def test_save_file_requires_list_of_lists_for_delimited_formats(
+ tmp_path: Path,
+ file_type: FileType,
+ data: Any,
+) -> None:
+ target = tmp_path / "rows.data"
+ action = make_action(target, file_type=file_type)
+
+ with pytest.raises(ValueError, match="requires a list of lists"):
+ await action.save_file(data)
+
+
+@pytest.mark.asyncio
+async def test_save_file_requires_dict_for_xml(tmp_path: Path) -> None:
+ target = tmp_path / "data.xml"
+ action = make_action(target, file_type=FileType.XML)
+
+ with pytest.raises(
+ ValueError, match="XML file type requires data to be a dictionary"
+ ):
+ await action.save_file(["not", "a", "dict"])
+
+
+@pytest.mark.asyncio
+async def test_save_file_raises_for_unsupported_internal_file_type(
+ tmp_path: Path,
+) -> None:
+ target = tmp_path / "data.out"
+ action = make_action(target, file_type=FileType.TEXT)
+ action._file_type = object() # Force the defensive unsupported-type branch.
+
+ with pytest.raises(ValueError, match="Unsupported file type"):
+ await action.save_file("hello")
+
+
+@pytest.mark.asyncio
+async def test_save_file_reraises_write_errors(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ target = tmp_path / "out.txt"
+ action = make_action(target, file_type=FileType.TEXT)
+
+ def fake_write_text(self: Path, data: str, *, encoding: str | None = None) -> int:
+ raise OSError("disk is unavailable")
+
+ monkeypatch.setattr(Path, "write_text", fake_write_text)
+
+ with pytest.raises(OSError, match="disk is unavailable"):
+ await action.save_file("hello")
+
+
+@pytest.mark.asyncio
+async def test_run_saves_configured_data_and_triggers_success_lifecycle(
+ tmp_path: Path,
+) -> None:
+ target = tmp_path / "out.txt"
+ action = make_action(target, file_type=FileType.TEXT, data="hello")
+ calls = register_lifecycle_hooks(action)
+
+ result = await action("positional", ignored="kwarg")
+
+ assert result == str(target)
+ assert target.read_text(encoding="UTF-8") == "hello"
+ assert hook_types(calls) == [
+ HookType.BEFORE,
+ HookType.ON_SUCCESS,
+ HookType.AFTER,
+ HookType.ON_TEARDOWN,
+ ]
+ assert calls[0][1].args == ("positional",)
+ assert calls[0][1].kwargs == {"ignored": "kwarg"}
+ assert calls[0][1].action is action
+
+
+@pytest.mark.asyncio
+async def test_run_uses_data_from_kwargs_when_no_static_data_is_configured(
+ tmp_path: Path,
+) -> None:
+ target = tmp_path / "out.txt"
+ action = make_action(target, file_type=FileType.TEXT, data=None)
+
+ result = await action(data="from kwargs")
+
+ assert result == str(target)
+ assert target.read_text(encoding="UTF-8") == "from kwargs"
+
+
+@pytest.mark.asyncio
+async def test_run_triggers_error_lifecycle_and_reraises(tmp_path: Path) -> None:
+ action = make_action(None, data="hello")
+ calls = register_lifecycle_hooks(action)
+
+ with pytest.raises(ValueError, match="file_path must be set"):
+ await action()
+
+ assert hook_types(calls) == [
+ HookType.BEFORE,
+ HookType.ON_ERROR,
+ HookType.AFTER,
+ HookType.ON_TEARDOWN,
+ ]
+ assert isinstance(calls[1][1].exception, ValueError)
+
+
+@pytest.mark.asyncio
+async def test_preview_prints_tree_for_existing_file_when_overwrite_enabled(
+ tmp_path: Path,
+) -> None:
+ target = tmp_path / "out.txt"
+ target.write_text("existing", encoding="UTF-8")
+ action = make_action(target, file_type=FileType.TEXT, overwrite=True)
+ action.console = CaptureConsole()
+
+ await action.preview()
+
+ assert len(action.console.printed) == 1
+ printed_tree = action.console.printed[0][0][0]
+ assert isinstance(printed_tree, Tree)
+
+
+@pytest.mark.asyncio
+async def test_preview_prints_tree_for_existing_file_when_overwrite_disabled(
+ tmp_path: Path,
+) -> None:
+ target = tmp_path / "out.txt"
+ target.write_text("existing", encoding="UTF-8")
+ action = make_action(target, file_type=FileType.TEXT, overwrite=False)
+ action.console = CaptureConsole()
+
+ await action.preview()
+
+ assert len(action.console.printed) == 1
+ printed_tree = action.console.printed[0][0][0]
+ assert isinstance(printed_tree, Tree)
+
+
+@pytest.mark.asyncio
+async def test_preview_adds_to_existing_parent_without_printing(tmp_path: Path) -> None:
+ target = tmp_path / "out.txt"
+ action = make_action(target, file_type=FileType.JSON)
+ action.console = CaptureConsole()
+ parent = Tree("root")
+
+ await action.preview(parent=parent)
+
+ assert action.console.printed == []
+ assert len(parent.children) == 1
+
+
+def test_clone_preserves_configuration_but_returns_distinct_action(
+ tmp_path: Path,
+) -> None:
+ target = tmp_path / "out.json"
+ action = make_action(
+ target,
+ file_type=FileType.JSON,
+ mode="a",
+ encoding="utf-8",
+ data={"name": "falyx"},
+ overwrite=False,
+ create_dirs=False,
+ inject_last_result=True,
+ inject_into="payload",
+ never_prompt=True,
+ )
+
+ clone = action.clone()
+
+ assert clone is not action
+ assert clone.name == action.name
+ assert clone.file_path == action.file_path
+ assert clone.file_type == action.file_type
+ assert clone.mode == action.mode
+ assert clone.encoding == action.encoding
+ assert clone.data == action.data
+ assert clone.overwrite is action.overwrite
+ assert clone.create_dirs is action.create_dirs
+ assert clone.inject_last_result is action.inject_last_result
+ assert clone.inject_into == action.inject_into
+ assert clone.local_never_prompt is True
diff --git a/tests/test_actions/test_selection_action.py b/tests/test_actions/test_selection_action.py
index 3afd89a..39b17aa 100644
--- a/tests/test_actions/test_selection_action.py
+++ b/tests/test_actions/test_selection_action.py
@@ -1,7 +1,83 @@
-import pytest
+from __future__ import annotations
-from falyx.action import SelectionAction
-from falyx.selection import SelectionOption
+from typing import Any
+
+import pytest
+from rich.tree import Tree
+
+import falyx.action.selection_action as selection_action_module
+from falyx.action.action_types import SelectionReturnType
+from falyx.action.selection_action import SelectionAction
+from falyx.hook_manager import HookType
+from falyx.selection import SelectionOption, SelectionOptionMap
+from falyx.signals import CancelSignal
+
+
+class DummyPromptSession:
+ pass
+
+
+class CaptureConsole:
+ def __init__(self) -> None:
+ self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
+
+ def print(self, *args: Any, **kwargs: Any) -> None:
+ self.printed.append((args, kwargs))
+
+
+class FakeSharedContext:
+ def __init__(self, value: Any) -> None:
+ self.value = value
+
+ def last_result(self) -> Any:
+ return self.value
+
+
+class SizedButUnsupportedSelections:
+ def __len__(self) -> int:
+ return 0
+
+
+def make_action(selections: Any | None = None, **overrides: Any) -> SelectionAction:
+ defaults: dict[str, Any] = {
+ "name": "ChooseThing",
+ "selections": (
+ selections if selections is not None else ["alpha", "beta", "gamma"]
+ ),
+ "prompt_session": DummyPromptSession(),
+ }
+ defaults.update(overrides)
+ return SelectionAction(**defaults)
+
+
+def make_option_map_action(**overrides: Any) -> SelectionAction:
+ return make_action(
+ {
+ "0": SelectionOption("Development", "dev"),
+ "1": SelectionOption("Production", "prod"),
+ "2": SelectionOption("Staging", "stage"),
+ },
+ **overrides,
+ )
+
+
+def register_lifecycle_hooks(action: SelectionAction) -> list[tuple[HookType, Any]]:
+ calls: list[tuple[HookType, Any]] = []
+
+ def make_hook(hook_type: HookType):
+ def hook(context: Any) -> None:
+ calls.append((hook_type, context))
+
+ return hook
+
+ for hook_type in HookType:
+ action.hooks.register(hook_type, make_hook(hook_type))
+
+ return calls
+
+
+def hook_types(calls: list[tuple[HookType, Any]]) -> list[HookType]:
+ return [hook_type for hook_type, _ in calls]
@pytest.mark.asyncio
@@ -285,3 +361,586 @@ async def test_selection_prompt_map_never_prompt_by_value_wildcard():
result = await action()
assert result == ["Beta Service", "Alpha Service"]
+
+
+def test_init_normalizes_list_tuple_set_and_basic_configuration() -> None:
+ session = DummyPromptSession()
+
+ tuple_action = SelectionAction(
+ name="TupleChoice",
+ selections=("red", "blue"),
+ title="Colors",
+ columns=2,
+ prompt_message="[bold]Pick >[/] ",
+ default_selection="1",
+ number_selections=1,
+ separator=";",
+ allow_duplicates=True,
+ return_type="value",
+ prompt_session=session,
+ never_prompt=True,
+ show_table=False,
+ )
+
+ assert tuple_action.selections == ["red", "blue"]
+ assert tuple_action.return_type is SelectionReturnType.VALUE
+ assert tuple_action.title == "Colors"
+ assert tuple_action.columns == 2
+ assert tuple_action.default_selection == "1"
+ assert tuple_action.separator == ";"
+ assert tuple_action.allow_duplicates is True
+ assert tuple_action.prompt_session is session
+ assert tuple_action.local_never_prompt is True
+ assert tuple_action.show_table is False
+
+ set_action = make_action({"red", "blue"})
+ assert sorted(set_action.selections) == ["blue", "red"]
+
+
+def test_init_converts_plain_dict_to_selection_option_map() -> None:
+ action = make_action({"dev": "Development", "prod": "Production"})
+
+ assert isinstance(action.selections, SelectionOptionMap)
+ assert list(action.selections) == ["0", "1"]
+ assert action.selections["0"] == SelectionOption("dev", "Development")
+ assert action.selections["1"] == SelectionOption("prod", "Production")
+
+
+def test_init_preserves_selection_option_map_values() -> None:
+ action = make_action(
+ {
+ "D": SelectionOption("Development", "dev", style="green"),
+ "P": SelectionOption("Production", "prod", style="red"),
+ }
+ )
+
+ assert isinstance(action.selections, SelectionOptionMap)
+ assert action.selections["D"].description == "Development"
+ assert action.selections["P"].value == "prod"
+
+
+@pytest.mark.parametrize("number_selections", [1, 2, "*"])
+def test_number_selections_accepts_positive_ints_and_star(
+ number_selections: int | str,
+) -> None:
+ action = make_action(number_selections=number_selections)
+
+ assert action.number_selections == number_selections
+
+
+@pytest.mark.parametrize("number_selections", [0, -1, "many", object()])
+def test_number_selections_rejects_invalid_values(number_selections: Any) -> None:
+ action = make_action()
+
+ with pytest.raises(ValueError, match="number_selections"):
+ action.number_selections = number_selections
+
+
+@pytest.mark.parametrize(
+ ("selections", "error_type", "match"),
+ [
+ ({1: SelectionOption("One", 1)}, ValueError, "Invalid dictionary format"),
+ (123, TypeError, "selections"),
+ ],
+)
+def test_selections_setter_rejects_invalid_inputs(
+ selections: Any,
+ error_type: type[BaseException],
+ match: str,
+) -> None:
+ with pytest.raises(error_type, match=match):
+ make_action(selections)
+
+
+def test_find_cancel_key_returns_numeric_gap_for_dict_and_next_index_for_list() -> None:
+ dict_action = make_action(
+ {
+ "0": SelectionOption("Zero", 0),
+ "2": SelectionOption("Two", 2),
+ }
+ )
+ list_action = make_action(["zero", "one"])
+
+ assert dict_action._find_cancel_key() == "1"
+ assert list_action._find_cancel_key() == "2"
+
+
+def test_cancel_key_setter_rejects_non_string_values() -> None:
+ action = make_action()
+
+ with pytest.raises(TypeError, match="Cancel key must be a string"):
+ action.cancel_key = 1 # type: ignore[assignment]
+
+
+def test_cancel_key_setter_rejects_existing_dict_key() -> None:
+ action = make_action({"A": SelectionOption("Alpha", "alpha")})
+
+ with pytest.raises(
+ ValueError, match="Cancel key cannot be one of the selection keys"
+ ):
+ action.cancel_key = "A"
+
+
+@pytest.mark.parametrize("cancel_key", ["x", "3"])
+def test_cancel_key_setter_rejects_invalid_list_cancel_key(cancel_key: str) -> None:
+ action = make_action(["alpha", "beta"])
+
+ with pytest.raises(ValueError, match="cancel_key must be a digit"):
+ action.cancel_key = cancel_key
+
+
+def test_cancel_formatter_marks_cancel_key_and_formats_regular_items() -> None:
+ action = make_action(["alpha", "beta"])
+ action.cancel_key = "2"
+
+ assert "Cancel" in action.cancel_formatter(2, "Cancel")
+ assert action.cancel_formatter(1, "beta").endswith("beta")
+
+
+def test_get_infer_target_disables_signature_inference() -> None:
+ action = make_action()
+
+ assert action.get_infer_target() == (None, None)
+
+
+@pytest.mark.parametrize(
+ ("return_type", "keys", "expected"),
+ [
+ (SelectionReturnType.KEY, "0", "0"),
+ (SelectionReturnType.KEY, ["0", "2"], ["0", "2"]),
+ (SelectionReturnType.VALUE, "1", "prod"),
+ (SelectionReturnType.VALUE, ["0", "2"], ["dev", "stage"]),
+ (SelectionReturnType.DESCRIPTION, "0", "Development"),
+ (
+ SelectionReturnType.DESCRIPTION,
+ ["0", "2"],
+ ["Development", "Staging"],
+ ),
+ (
+ SelectionReturnType.DESCRIPTION_VALUE,
+ "1",
+ {"Production": "prod"},
+ ),
+ (
+ SelectionReturnType.DESCRIPTION_VALUE,
+ ["0", "2"],
+ {"Development": "dev", "Staging": "stage"},
+ ),
+ ],
+)
+def test_get_result_from_keys_returns_configured_shape(
+ return_type: SelectionReturnType,
+ keys: str | list[str],
+ expected: Any,
+) -> None:
+ action = make_option_map_action(return_type=return_type)
+
+ assert action._get_result_from_keys(keys) == expected
+
+
+@pytest.mark.parametrize("keys", ["0", ["0", "1"]])
+def test_get_result_from_keys_returns_items_mapping(keys: str | list[str]) -> None:
+ action = make_option_map_action(return_type=SelectionReturnType.ITEMS)
+
+ result = action._get_result_from_keys(keys)
+
+ assert isinstance(result, dict)
+ assert set(result) == ({keys} if isinstance(keys, str) else set(keys))
+ assert all(isinstance(option, SelectionOption) for option in result.values())
+
+
+def test_get_result_from_keys_requires_dict_selections() -> None:
+ action = make_action(["alpha", "beta"])
+
+ with pytest.raises(TypeError, match="Selections must be a dictionary"):
+ action._get_result_from_keys("0")
+
+
+def test_get_result_from_keys_rejects_unsupported_return_type() -> None:
+ action = make_option_map_action()
+ action.return_type = object() # Force defensive branch unreachable through __init__.
+
+ with pytest.raises(ValueError, match="Unsupported return type"):
+ action._get_result_from_keys("0")
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("maybe_result", "expected"),
+ [
+ ("1", "1"),
+ ("prod", "1"),
+ ("Production", "1"),
+ ],
+)
+async def test_resolve_single_default_maps_dict_key_value_and_description(
+ maybe_result: str,
+ expected: str,
+) -> None:
+ action = make_option_map_action()
+
+ assert await action._resolve_single_default(maybe_result) == expected
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("maybe_result", "expected"),
+ [
+ ("1", "1"),
+ ("beta", "1"),
+ ("missing", ""),
+ ],
+)
+async def test_resolve_single_default_maps_list_index_or_value(
+ maybe_result: str,
+ expected: str,
+) -> None:
+ action = make_action(["alpha", "beta"])
+
+ assert await action._resolve_single_default(maybe_result) == expected
+
+
+@pytest.mark.asyncio
+async def test_resolve_effective_default_uses_first_value_for_single_selection_defaults() -> (
+ None
+):
+ action = make_action(["alpha", "beta"], default_selection=["beta"])
+
+ assert await action._resolve_effective_default() == "1"
+
+
+@pytest.mark.asyncio
+async def test_resolve_effective_default_uses_first_last_result_for_single_selection() -> (
+ None
+):
+ action = make_action(["alpha", "beta"])
+ action.shared_context = FakeSharedContext(["beta"])
+
+ assert await action._resolve_effective_default() == "1"
+
+
+@pytest.mark.asyncio
+async def test_resolve_effective_default_joins_multi_selection_defaults() -> None:
+ action = make_action(
+ ["alpha", "beta", "gamma"],
+ default_selection=["alpha", "gamma"],
+ number_selections=2,
+ )
+
+ assert await action._resolve_effective_default() == "0,2"
+
+
+@pytest.mark.asyncio
+async def test_resolve_effective_default_joins_multi_selection_last_result() -> None:
+ action = make_action(["alpha", "beta", "gamma"], number_selections=2)
+ action.shared_context = FakeSharedContext(["alpha", "gamma"])
+
+ assert await action._resolve_effective_default() == "0,2"
+
+
+@pytest.mark.asyncio
+async def test_resolve_effective_default_allows_unbounded_multi_selection_last_result() -> (
+ None
+):
+ action = make_action(["alpha", "beta", "gamma"], number_selections="*")
+ action.shared_context = FakeSharedContext(["alpha", "beta", "gamma"])
+
+ assert await action._resolve_effective_default() == "0,1,2"
+
+
+@pytest.mark.asyncio
+async def test_resolve_effective_default_rejects_default_length_mismatch() -> None:
+ action = make_action(
+ ["alpha", "beta", "gamma"],
+ default_selection=["alpha"],
+ number_selections=2,
+ )
+
+ with pytest.raises(ValueError, match="default_selection has a different length"):
+ await action._resolve_effective_default()
+
+
+@pytest.mark.asyncio
+async def test_resolve_effective_default_rejects_last_result_length_mismatch() -> None:
+ action = make_action(["alpha", "beta", "gamma"], number_selections=2)
+ action.shared_context = FakeSharedContext(["alpha"])
+
+ with pytest.raises(ValueError, match="last_result has a different length"):
+ await action._resolve_effective_default()
+
+
+@pytest.mark.asyncio
+async def test_resolve_effective_default_warns_when_injected_result_is_unusable(
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ action = make_action(
+ ["alpha", "beta"],
+ inject_last_result=True,
+ number_selections=2,
+ )
+ action.shared_context = FakeSharedContext("missing")
+
+ assert await action._resolve_effective_default() == ""
+ assert "Injected last result" in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_run_list_headless_single_selection_uses_default() -> None:
+ action = make_action(["alpha", "beta"], never_prompt=True, default_selection="1")
+
+ result = await action()
+
+ assert result == "beta"
+
+
+@pytest.mark.asyncio
+async def test_run_list_headless_multi_selection_uses_default_list() -> None:
+ action = make_action(
+ ["alpha", "beta", "gamma"],
+ never_prompt=True,
+ default_selection=["alpha", "gamma"],
+ number_selections=2,
+ )
+
+ result = await action()
+
+ assert result == ["alpha", "gamma"]
+
+
+@pytest.mark.asyncio
+async def test_run_dict_headless_single_selection_returns_value() -> None:
+ action = make_option_map_action(never_prompt=True, default_selection="1")
+
+ result = await action()
+
+ assert result == "prod"
+
+
+@pytest.mark.asyncio
+async def test_run_dict_headless_multi_selection_returns_configured_shape() -> None:
+ action = make_option_map_action(
+ never_prompt=True,
+ default_selection=["0", "2"],
+ number_selections=2,
+ return_type=SelectionReturnType.DESCRIPTION_VALUE,
+ )
+
+ result = await action()
+
+ assert result == {"Development": "dev", "Staging": "stage"}
+
+
+@pytest.mark.asyncio
+async def test_run_list_interactive_uses_prompt_for_index(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ action = make_action(["alpha", "beta"], never_prompt=False, show_table=False)
+
+ async def fake_prompt_for_index(*args: Any, **kwargs: Any) -> int:
+ assert kwargs["prompt_session"] is action.prompt_session
+ assert kwargs["show_table"] is False
+ assert kwargs["cancel_key"] == "2"
+ return 1
+
+ monkeypatch.setattr(
+ selection_action_module, "prompt_for_index", fake_prompt_for_index
+ )
+
+ result = await action()
+
+ assert result == "beta"
+
+
+@pytest.mark.asyncio
+async def test_run_dict_interactive_uses_prompt_for_selection(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ action = make_option_map_action(never_prompt=False, show_table=False)
+
+ async def fake_prompt_for_selection(*args: Any, **kwargs: Any) -> str:
+ assert kwargs["prompt_session"] is action.prompt_session
+ assert kwargs["show_table"] is False
+ assert kwargs["cancel_key"] == "3"
+ return "2"
+
+ monkeypatch.setattr(
+ selection_action_module,
+ "prompt_for_selection",
+ fake_prompt_for_selection,
+ )
+
+ result = await action()
+
+ assert result == "stage"
+
+
+@pytest.mark.asyncio
+async def test_run_raises_when_never_prompt_has_no_effective_default() -> None:
+ action = make_action(["alpha", "beta"], never_prompt=True)
+
+ with pytest.raises(ValueError, match="never_prompt"):
+ await action()
+
+
+@pytest.mark.asyncio
+async def test_run_list_cancel_triggers_error_and_teardown_hooks(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ action = make_action(["alpha", "beta"], never_prompt=True, default_selection="0")
+ calls = register_lifecycle_hooks(action)
+
+ async def fake_resolve_effective_default() -> str:
+ return "4"
+
+ monkeypatch.setattr(
+ action, "_resolve_effective_default", fake_resolve_effective_default
+ )
+
+ with pytest.raises(IndexError):
+ await action()
+
+ assert HookType.BEFORE in hook_types(calls)
+ assert HookType.ON_ERROR in hook_types(calls)
+ assert HookType.AFTER in hook_types(calls)
+ assert HookType.ON_TEARDOWN in hook_types(calls)
+ error_contexts = [
+ context for hook_type, context in calls if hook_type is HookType.ON_ERROR
+ ]
+ assert isinstance(error_contexts[0].exception, IndexError)
+
+
+@pytest.mark.asyncio
+async def test_run_dict_cancel_triggers_cancel_signal(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ action = make_option_map_action(never_prompt=True, default_selection="0")
+
+ async def fake_resolve_effective_default() -> str:
+ return "3"
+
+ monkeypatch.setattr(
+ action, "_resolve_effective_default", fake_resolve_effective_default
+ )
+
+ with pytest.raises(CancelSignal):
+ await action()
+
+
+@pytest.mark.asyncio
+async def test_run_unsupported_selection_storage_triggers_error_lifecycle(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ action = make_action(["alpha"], never_prompt=False)
+ action._selections = SizedButUnsupportedSelections() # type: ignore[assignment]
+ calls = register_lifecycle_hooks(action)
+
+ async def fake_resolve_effective_default() -> str:
+ return ""
+
+ monkeypatch.setattr(
+ action, "_resolve_effective_default", fake_resolve_effective_default
+ )
+
+ with pytest.raises(TypeError, match="selections"):
+ await action()
+
+ assert HookType.ON_ERROR in hook_types(calls)
+ error_contexts = [
+ context for hook_type, context in calls if hook_type is HookType.ON_ERROR
+ ]
+ assert isinstance(error_contexts[0].exception, TypeError)
+
+
+@pytest.mark.asyncio
+async def test_run_success_triggers_success_after_and_teardown_hooks() -> None:
+ action = make_action(["alpha", "beta"], never_prompt=True, default_selection="0")
+ calls = register_lifecycle_hooks(action)
+
+ result = await action()
+
+ assert result == "alpha"
+ assert hook_types(calls).count(HookType.BEFORE) == 1
+ assert hook_types(calls).count(HookType.ON_SUCCESS) == 1
+ assert hook_types(calls).count(HookType.AFTER) == 1
+ assert hook_types(calls).count(HookType.ON_TEARDOWN) == 1
+ success_contexts = [
+ context for hook_type, context in calls if hook_type is HookType.ON_SUCCESS
+ ]
+ assert success_contexts[0].result == "alpha"
+
+
+@pytest.mark.asyncio
+async def test_preview_prints_tree_when_no_parent() -> None:
+ action = make_option_map_action(default_selection="1", never_prompt=True)
+ console = CaptureConsole()
+ action.console = console # type: ignore[assignment]
+
+ await action.preview()
+
+ assert len(console.printed) == 1
+ assert "SelectionAction" in str(console.printed[0][0][0].label)
+
+
+@pytest.mark.asyncio
+async def test_preview_adds_to_parent_when_parent_is_provided() -> None:
+ action = make_action(["alpha", "beta"], default_selection="0")
+ parent = Tree("Root")
+ console = CaptureConsole()
+ action.console = console # type: ignore[assignment]
+
+ await action.preview(parent=parent)
+
+ assert console.printed == []
+ assert len(parent.children) == 1
+ assert "SelectionAction" in str(parent.children[0].label)
+
+
+def test_str_includes_action_configuration() -> None:
+ action = make_action(["alpha", "beta"], return_type=SelectionReturnType.KEY)
+
+ text = str(action)
+
+ assert "SelectionAction" in text
+ assert "ChooseThing" in text
+ assert "KEY" in text or "key" in text
+
+
+def test_clone_copies_selection_action_configuration() -> None:
+ session = DummyPromptSession()
+ action = SelectionAction(
+ name="CloneMe",
+ selections={"A": SelectionOption("Alpha", "alpha", style="green")},
+ title="Letters",
+ columns=3,
+ prompt_message="Choose letter > ",
+ default_selection="A",
+ number_selections="*",
+ separator=";",
+ allow_duplicates=True,
+ inject_last_result=True,
+ inject_into="choice",
+ return_type=SelectionReturnType.DESCRIPTION,
+ prompt_session=session,
+ never_prompt=True,
+ show_table=False,
+ )
+
+ clone = action.clone()
+
+ assert clone is not action
+ assert clone.name == action.name
+ assert clone.title == action.title
+ assert clone.columns == action.columns
+ assert clone.prompt_message == action.prompt_message
+ assert clone.default_selection == action.default_selection
+ assert clone.number_selections == action.number_selections
+ assert clone.separator == action.separator
+ assert clone.allow_duplicates == action.allow_duplicates
+ assert clone.inject_last_result is True
+ assert clone.inject_into == "choice"
+ assert clone.return_type is SelectionReturnType.DESCRIPTION
+ assert clone.prompt_session is session
+ assert clone.local_never_prompt is True
+ assert clone.show_table is False
+ assert clone.selections is not action.selections
+ assert clone.selections["A"].description == "Alpha"
diff --git a/tests/test_actions/test_selection_file_action.py b/tests/test_actions/test_selection_file_action.py
new file mode 100644
index 0000000..0989cbf
--- /dev/null
+++ b/tests/test_actions/test_selection_file_action.py
@@ -0,0 +1,598 @@
+from __future__ import annotations
+
+import xml.etree.ElementTree as ET
+from pathlib import Path
+from typing import Any
+
+import pytest
+import toml
+import yaml
+from rich.tree import Tree
+
+import falyx.action.select_file_action as select_file_module
+from falyx.action.action_types import FileType
+from falyx.action.select_file_action import SelectFileAction
+from falyx.hook_manager import HookType
+from falyx.selection import SelectionOption
+from falyx.signals import CancelSignal
+
+
+class DummyPromptSession:
+ pass
+
+
+class CaptureConsole:
+ def __init__(self) -> None:
+ self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
+
+ def print(self, *args: Any, **kwargs: Any) -> None:
+ self.printed.append((args, kwargs))
+
+
+def make_action(directory: Path, **overrides: Any) -> SelectFileAction:
+ defaults: dict[str, Any] = {
+ "name": "ChooseFile",
+ "directory": directory,
+ "prompt_session": DummyPromptSession(),
+ }
+ defaults.update(overrides)
+ return SelectFileAction(**defaults)
+
+
+def write_sample_files(directory: Path) -> dict[str, Path]:
+ paths = {
+ "text": directory / "note.txt",
+ "json": directory / "config.json",
+ "yaml": directory / "config.yaml",
+ "toml": directory / "config.toml",
+ "csv": directory / "rows.csv",
+ "tsv": directory / "rows.tsv",
+ "xml": directory / "doc.xml",
+ }
+ paths["text"].write_text("hello\n", encoding="UTF-8")
+ paths["json"].write_text('{"name": "falyx", "count": 2}', encoding="UTF-8")
+ paths["yaml"].write_text("name: falyx\nenabled: true\n", encoding="UTF-8")
+ paths["toml"].write_text('name = "falyx"\ncount = 2\n', encoding="UTF-8")
+ paths["csv"].write_text("name,count\nfalyx,2\n", encoding="UTF-8")
+ paths["tsv"].write_text("name\tcount\nfalyx\t2\n", encoding="UTF-8")
+ paths["xml"].write_text("falyx", encoding="UTF-8")
+ return paths
+
+
+def register_lifecycle_hooks(action: SelectFileAction) -> list[tuple[HookType, Any]]:
+ calls: list[tuple[HookType, Any]] = []
+
+ def make_hook(hook_type: HookType):
+ def hook(context):
+ calls.append((hook_type, context))
+
+ return hook
+
+ for hook_type in HookType:
+ action.hooks.register(hook_type, make_hook(hook_type))
+
+ return calls
+
+
+def hook_types(calls: list[tuple[HookType, Any]]) -> list[HookType]:
+ return [hook_type for hook_type, _ in calls]
+
+
+def test_init_normalizes_configuration_and_string_return_type(tmp_path: Path) -> None:
+ session = DummyPromptSession()
+
+ action = SelectFileAction(
+ "ChooseConfig",
+ tmp_path,
+ title="Configs",
+ columns=4,
+ prompt_message="[bold]Pick >[/] ",
+ style="green",
+ suffix_filter=".json",
+ return_type="json",
+ encoding="utf-8",
+ number_selections="*",
+ separator=";",
+ allow_duplicates=True,
+ prompt_session=session,
+ never_prompt=True,
+ )
+
+ assert action.name == "ChooseConfig"
+ assert action.directory == tmp_path.resolve()
+ assert action.title == "Configs"
+ assert action.columns == 4
+ assert action.suffix_filter == ".json"
+ assert action.return_type == FileType.JSON
+ assert action.encoding == "utf-8"
+ assert action.number_selections == "*"
+ assert action.separator == ";"
+ assert action.allow_duplicates is True
+ assert action.prompt_session is session
+ assert action.local_never_prompt is True
+ assert "ChooseConfig" in str(action)
+ assert ".json" in str(action)
+
+
+@pytest.mark.parametrize("number_selections", [1, 2, "*"])
+def test_number_selections_accepts_positive_ints_and_star(
+ tmp_path: Path,
+ number_selections: int | str,
+) -> None:
+ action = make_action(tmp_path, number_selections=number_selections)
+
+ assert action.number_selections == number_selections
+
+
+@pytest.mark.parametrize("number_selections", [0, -1, "many", object()])
+def test_number_selections_rejects_invalid_values(
+ tmp_path: Path,
+ number_selections: Any,
+) -> None:
+ action = make_action(tmp_path)
+
+ with pytest.raises(ValueError, match="number_selections"):
+ action.number_selections = number_selections
+
+
+def test_get_options_uses_numeric_keys_and_selection_options(tmp_path: Path) -> None:
+ first = tmp_path / "a.txt"
+ second = tmp_path / "b.txt"
+ first.write_text("a", encoding="UTF-8")
+ second.write_text("b", encoding="UTF-8")
+ action = make_action(tmp_path, style="cyan")
+
+ options = action.get_options([first, second])
+
+ assert list(options) == ["0", "1"]
+ assert options["0"] == SelectionOption(
+ description="a.txt",
+ value=first,
+ style="cyan",
+ )
+ assert options["1"].description == "b.txt"
+ assert options["1"].value == second
+
+
+def test_find_cancel_key_returns_first_numeric_gap_or_next_index(tmp_path: Path) -> None:
+ action = make_action(tmp_path)
+
+ assert action._find_cancel_key({"0": object(), "2": object()}) == "1"
+ assert action._find_cancel_key({"0": object(), "1": object()}) == "2"
+ assert action._find_cancel_key({}) == "0"
+
+
+def test_get_infer_target_disables_signature_inference(tmp_path: Path) -> None:
+ action = make_action(tmp_path)
+
+ assert action.get_infer_target() == (None, None)
+
+
+@pytest.mark.parametrize(
+ ("return_type", "file_key", "expected"),
+ [
+ (FileType.TEXT, "text", "hello\n"),
+ (FileType.PATH, "text", "PATH"),
+ (FileType.JSON, "json", {"name": "falyx", "count": 2}),
+ (FileType.YAML, "yaml", {"name": "falyx", "enabled": True}),
+ (FileType.TOML, "toml", {"name": "falyx", "count": 2}),
+ (FileType.CSV, "csv", [["name", "count"], ["falyx", "2"]]),
+ (FileType.TSV, "tsv", [["name", "count"], ["falyx", "2"]]),
+ ],
+)
+def test_parse_file_returns_requested_representation(
+ tmp_path: Path,
+ return_type: FileType,
+ file_key: str,
+ expected: Any,
+) -> None:
+ files = write_sample_files(tmp_path)
+ action = make_action(tmp_path, return_type=return_type)
+
+ result = action.parse_file(files[file_key])
+
+ if expected == "PATH":
+ assert result == files[file_key]
+ else:
+ assert result == expected
+
+
+def test_parse_file_returns_xml_root(tmp_path: Path) -> None:
+ files = write_sample_files(tmp_path)
+ action = make_action(tmp_path, return_type=FileType.XML)
+
+ result = action.parse_file(files["xml"])
+
+ assert isinstance(result, ET.Element)
+ assert result.tag == "root"
+ assert result.findtext("name") == "falyx"
+
+
+def test_clone_preserves_configuration_but_returns_distinct_action(
+ tmp_path: Path,
+) -> None:
+ session = DummyPromptSession()
+ action = make_action(
+ tmp_path,
+ title="Pick a data file",
+ columns=2,
+ prompt_message="Select > ",
+ style="magenta",
+ suffix_filter=".json",
+ return_type=FileType.JSON,
+ encoding="utf-8",
+ number_selections=2,
+ separator="|",
+ allow_duplicates=True,
+ prompt_session=session,
+ never_prompt=True,
+ )
+
+ clone = action.clone()
+
+ assert clone is not action
+ assert clone.name == action.name
+ assert clone.directory == action.directory
+ assert clone.title == action.title
+ assert clone.columns == action.columns
+ assert clone.prompt_message == action.prompt_message
+ assert clone.style == action.style
+ assert clone.suffix_filter == action.suffix_filter
+ assert clone.return_type == action.return_type
+ assert clone.encoding == action.encoding
+ assert clone.number_selections == action.number_selections
+ assert clone.separator == action.separator
+ assert clone.allow_duplicates == action.allow_duplicates
+ assert clone.prompt_session is session
+ assert clone.local_never_prompt is True
+
+
+@pytest.mark.asyncio
+async def test_preview_prints_tree_when_no_parent_is_given(tmp_path: Path) -> None:
+ write_sample_files(tmp_path)
+ action = make_action(tmp_path, suffix_filter=".json", return_type=FileType.JSON)
+ action.console = CaptureConsole()
+
+ await action.preview()
+
+ assert len(action.console.printed) == 1
+ printed_tree = action.console.printed[0][0][0]
+ assert isinstance(printed_tree, Tree)
+
+
+@pytest.mark.asyncio
+async def test_preview_adds_to_existing_parent_and_limits_file_sample(
+ tmp_path: Path,
+) -> None:
+ for index in range(12):
+ (tmp_path / f"config-{index}.json").write_text("{}", encoding="UTF-8")
+ (tmp_path / "ignore.txt").write_text("ignored", encoding="UTF-8")
+ action = make_action(tmp_path, suffix_filter=".json", return_type=FileType.JSON)
+ parent = Tree("root")
+
+ await action.preview(parent=parent)
+
+ assert len(parent.children) == 1
+ action_tree = parent.children[0]
+ rendered_labels = [str(child.label) for child in action_tree.children]
+ assert any("Suffix filter" in label and ".json" in label for label in rendered_labels)
+ file_list = next(
+ child for child in action_tree.children if str(child.label) == "[dim]Files:[/]"
+ )
+ assert len(file_list.children) == 11
+ assert "... (2 more)" in str(file_list.children[-1].label)
+
+
+@pytest.mark.asyncio
+async def test_preview_reports_directory_scan_errors(tmp_path: Path) -> None:
+ missing_dir = tmp_path / "missing"
+ action = make_action(missing_dir)
+ parent = Tree("root")
+
+ await action.preview(parent=parent)
+
+ action_tree = parent.children[0]
+ assert any(
+ "Error scanning directory" in str(child.label) for child in action_tree.children
+ )
+
+
+@pytest.mark.asyncio
+async def test_run_raises_for_missing_directory_and_triggers_error_lifecycle(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ action = make_action(tmp_path / "missing")
+ calls = register_lifecycle_hooks(action)
+ recorded: list[Any] = []
+ monkeypatch.setattr(select_file_module.er, "record", recorded.append)
+
+ with pytest.raises(FileNotFoundError, match="does not exist"):
+ await action("arg", flag=True)
+
+ assert hook_types(calls) == [
+ HookType.BEFORE,
+ HookType.ON_ERROR,
+ HookType.AFTER,
+ HookType.ON_TEARDOWN,
+ ]
+ assert recorded
+ assert isinstance(recorded[0].exception, FileNotFoundError)
+ assert recorded[0].args == ("arg",)
+ assert recorded[0].kwargs == {"flag": True}
+
+
+@pytest.mark.asyncio
+async def test_run_raises_when_directory_path_is_file(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ directory_path = tmp_path / "not-a-dir.txt"
+ directory_path.write_text("not a directory", encoding="UTF-8")
+ action = make_action(directory_path)
+ calls = register_lifecycle_hooks(action)
+ monkeypatch.setattr(select_file_module.er, "record", lambda context: None)
+
+ with pytest.raises(NotADirectoryError, match="is not a directory"):
+ await action()
+
+ assert HookType.ON_ERROR in hook_types(calls)
+
+
+@pytest.mark.asyncio
+async def test_run_raises_when_suffix_filter_matches_no_files(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ (tmp_path / "note.txt").write_text("hello", encoding="UTF-8")
+ action = make_action(tmp_path, suffix_filter=".json")
+ calls = register_lifecycle_hooks(action)
+ monkeypatch.setattr(select_file_module.er, "record", lambda context: None)
+
+ with pytest.raises(FileNotFoundError, match="No files found"):
+ await action()
+
+ assert HookType.ON_ERROR in hook_types(calls)
+
+
+@pytest.mark.asyncio
+async def test_run_single_selection_returns_parsed_file_and_passes_prompt_options(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ selected = tmp_path / "note.txt"
+ selected.write_text("selected", encoding="UTF-8")
+ (tmp_path / "other.json").write_text("{}", encoding="UTF-8")
+ action = make_action(
+ tmp_path,
+ suffix_filter=".txt",
+ return_type=FileType.TEXT,
+ number_selections=1,
+ separator=";",
+ allow_duplicates=True,
+ )
+ calls = register_lifecycle_hooks(action)
+ recorded: list[Any] = []
+ prompt_calls: list[dict[str, Any]] = []
+ render_calls: list[dict[str, Any]] = []
+ monkeypatch.setattr(select_file_module.er, "record", recorded.append)
+
+ def fake_render_selection_dict_table(**kwargs: Any) -> object:
+ render_calls.append(kwargs)
+ return object()
+
+ async def fake_prompt_for_selection(valid_keys, table, **kwargs: Any) -> str:
+ prompt_calls.append({"valid_keys": list(valid_keys), "table": table, **kwargs})
+ return "0"
+
+ monkeypatch.setattr(
+ select_file_module,
+ "render_selection_dict_table",
+ fake_render_selection_dict_table,
+ )
+ monkeypatch.setattr(
+ select_file_module,
+ "prompt_for_selection",
+ fake_prompt_for_selection,
+ )
+
+ result = await action()
+
+ assert result == "selected"
+ assert hook_types(calls) == [
+ HookType.BEFORE,
+ HookType.ON_SUCCESS,
+ HookType.AFTER,
+ HookType.ON_TEARDOWN,
+ ]
+ assert recorded[0].result == "selected"
+ assert render_calls[0]["title"] == action.title
+ assert render_calls[0]["columns"] == action.columns
+ assert set(render_calls[0]["selections"]) == {"0", "1"}
+ assert prompt_calls[0]["valid_keys"] == ["0", "1"]
+ assert prompt_calls[0]["prompt_session"] is action.prompt_session
+ assert prompt_calls[0]["prompt_message"] == action.prompt_message
+ assert prompt_calls[0]["number_selections"] == 1
+ assert prompt_calls[0]["separator"] == ";"
+ assert prompt_calls[0]["allow_duplicates"] is True
+ assert prompt_calls[0]["cancel_key"] == "1"
+
+
+@pytest.mark.asyncio
+async def test_run_multi_selection_returns_results_for_each_selected_file(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ first = tmp_path / "a.txt"
+ second = tmp_path / "b.txt"
+ first.write_text("a", encoding="UTF-8")
+ second.write_text("b", encoding="UTF-8")
+ action = make_action(tmp_path, return_type=FileType.PATH, number_selections=2)
+ monkeypatch.setattr(select_file_module.er, "record", lambda context: None)
+ monkeypatch.setattr(
+ select_file_module,
+ "render_selection_dict_table",
+ lambda **kwargs: object(),
+ )
+
+ async def fake_prompt_for_selection(*args: Any, **kwargs: Any) -> list[str]:
+ return ["0", "1"]
+
+ monkeypatch.setattr(
+ select_file_module,
+ "prompt_for_selection",
+ fake_prompt_for_selection,
+ )
+
+ result = await action()
+
+ print(result)
+
+ assert result == [first, second] or result == [second, first]
+
+
+@pytest.mark.asyncio
+async def test_run_cancel_selection_raises_cancel_signal_and_skips_error_hook(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ (tmp_path / "a.txt").write_text("a", encoding="UTF-8")
+ action = make_action(tmp_path)
+ calls = register_lifecycle_hooks(action)
+ recorded: list[Any] = []
+ monkeypatch.setattr(select_file_module.er, "record", recorded.append)
+ monkeypatch.setattr(
+ select_file_module,
+ "render_selection_dict_table",
+ lambda **kwargs: object(),
+ )
+
+ async def fake_prompt_for_selection(*args: Any, **kwargs: Any) -> str:
+ return kwargs["cancel_key"]
+
+ monkeypatch.setattr(
+ select_file_module,
+ "prompt_for_selection",
+ fake_prompt_for_selection,
+ )
+
+ with pytest.raises(CancelSignal, match="User canceled"):
+ await action()
+
+ assert hook_types(calls) == [
+ HookType.BEFORE,
+ HookType.AFTER,
+ HookType.ON_TEARDOWN,
+ ]
+ assert recorded
+ assert recorded[0].exception is None
+
+
+def assert_parse_file_value_error(
+ action: SelectFileAction,
+ file: Path,
+ *,
+ expected_cause_type: (
+ type[BaseException] | tuple[type[BaseException], ...] | None
+ ) = None,
+) -> ValueError:
+ with pytest.raises(ValueError) as exc_info:
+ action.parse_file(file)
+
+ error = exc_info.value
+ assert f"Failed to parse {file.name} as" in str(error)
+ assert error.__cause__ is not None
+ if expected_cause_type is not None:
+ assert isinstance(error.__cause__, expected_cause_type)
+ return error
+
+
+def test_parse_file_wraps_invalid_json_errors(tmp_path: Path) -> None:
+ import json
+
+ broken = tmp_path / "broken.json"
+ broken.write_text('{"name": ', encoding="UTF-8")
+ action = make_action(tmp_path, return_type=FileType.JSON)
+
+ assert_parse_file_value_error(
+ action, broken, expected_cause_type=json.JSONDecodeError
+ )
+
+
+def test_parse_file_wraps_invalid_toml_errors(tmp_path: Path) -> None:
+ broken = tmp_path / "broken.toml"
+ broken.write_text('name = "falyx"\ncount = ', encoding="UTF-8")
+ action = make_action(tmp_path, return_type=FileType.TOML)
+
+ assert_parse_file_value_error(
+ action, broken, expected_cause_type=toml.TomlDecodeError
+ )
+
+
+def test_parse_file_wraps_invalid_yaml_errors(tmp_path: Path) -> None:
+ broken = tmp_path / "broken.yaml"
+ broken.write_text("name: [unterminated\n", encoding="UTF-8")
+ action = make_action(tmp_path, return_type=FileType.YAML)
+
+ assert_parse_file_value_error(action, broken, expected_cause_type=yaml.YAMLError)
+
+
+def test_parse_file_wraps_invalid_xml_errors(tmp_path: Path) -> None:
+ broken = tmp_path / "broken.xml"
+ broken.write_text("falyx", encoding="UTF-8")
+ action = make_action(tmp_path, return_type=FileType.XML)
+
+ assert_parse_file_value_error(action, broken, expected_cause_type=ET.ParseError)
+
+
+@pytest.mark.parametrize(
+ "return_type",
+ [
+ FileType.TEXT,
+ FileType.JSON,
+ FileType.YAML,
+ FileType.TOML,
+ FileType.CSV,
+ FileType.TSV,
+ FileType.XML,
+ ],
+)
+def test_parse_file_wraps_missing_file_errors(
+ tmp_path: Path, return_type: FileType
+) -> None:
+ missing = tmp_path / "missing.data"
+ action = make_action(tmp_path, return_type=return_type)
+
+ assert_parse_file_value_error(action, missing, expected_cause_type=FileNotFoundError)
+
+
+@pytest.mark.parametrize("return_type", [FileType.CSV, FileType.TSV])
+def test_parse_file_wraps_csv_style_open_errors(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ return_type: FileType,
+) -> None:
+ data_file = tmp_path / "rows.data"
+ data_file.write_text("name,count\nfalyx,2\n", encoding="UTF-8")
+ action = make_action(tmp_path, return_type=return_type)
+
+ def fake_open(*args: Any, **kwargs: Any) -> Any:
+ raise OSError("cannot open test file")
+
+ monkeypatch.setattr("builtins.open", fake_open)
+
+ error = assert_parse_file_value_error(action, data_file, expected_cause_type=OSError)
+ assert "cannot open test file" in str(error)
+
+
+def test_parse_file_wraps_unsupported_return_type_errors(tmp_path: Path) -> None:
+ data_file = tmp_path / "note.txt"
+ data_file.write_text("hello", encoding="UTF-8")
+ action = make_action(tmp_path, return_type=FileType.TEXT)
+ action.return_type = object() # Force the defensive unsupported-type branch.
+
+ error = assert_parse_file_value_error(
+ action, data_file, expected_cause_type=ValueError
+ )
+
+ assert "Unsupported return type" in str(error.__cause__)
diff --git a/tests/test_stress_actions.py b/tests/test_actions/test_stress_actions.py
similarity index 100%
rename from tests/test_stress_actions.py
rename to tests/test_actions/test_stress_actions.py
diff --git a/tests/test_command.py b/tests/test_command.py
index 7d6302c..83adad0 100644
--- a/tests/test_command.py
+++ b/tests/test_command.py
@@ -1,15 +1,89 @@
# test_command.py
-import pytest
+import logging
+from collections.abc import Callable
+from types import SimpleNamespace
+from typing import Any
-from falyx.action import Action, BaseIOAction, ChainedAction
+import pytest
+from pydantic import ValidationError
+
+import falyx.command as command_module
+from falyx.action import Action, BaseAction, BaseIOAction, ChainedAction
from falyx.command import Command
+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 HookType
+from falyx.parser.command_argument_parser import CommandArgumentParser
from falyx.retry import RetryPolicy
+from falyx.signals import CancelSignal
asyncio_default_fixture_loop_scope = "function"
-# --- Fixtures ---
+class CaptureConsole:
+ def __init__(self) -> None:
+ self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
+
+ def print(self, *args: Any, **kwargs: Any) -> None:
+ self.printed.append((args, kwargs))
+
+
+class FakeBaseAction(BaseAction):
+ def __init__(
+ self,
+ name: str = "FakeAction",
+ *,
+ result: Any = "ok",
+ infer_target: Callable[..., Any] | None = None,
+ metadata: dict[str, Any] | None = None,
+ never_prompt: bool | None = None,
+ ) -> None:
+ super().__init__(name, never_prompt=never_prompt)
+ self.result = result
+ self.infer_target = infer_target or (lambda: None)
+ self.metadata = metadata
+ self.preview_calls = 0
+ self.calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
+
+ async def _run(self, *args: Any, **kwargs: Any) -> Any:
+ self.calls.append((args, kwargs))
+ return self.result
+
+ async def preview(self, parent=None):
+ self.preview_calls += 1
+ if parent is not None:
+ parent.add("fake preview")
+ return None
+
+ def get_infer_target(self):
+ return self.infer_target, self.metadata
+
+ def clone(self) -> "FakeBaseAction":
+ return FakeBaseAction(
+ self.name,
+ result=self.result,
+ infer_target=self.infer_target,
+ metadata=self.metadata,
+ never_prompt=self.local_never_prompt,
+ )
+
+
+def make_command(**overrides: Any) -> Command:
+ defaults = dict(
+ key="D",
+ description="Deploy command",
+ action=lambda *args, **kwargs: {"args": args, "kwargs": kwargs},
+ auto_args=False,
+ )
+ defaults.update(overrides)
+ return Command.build(**defaults)
+
+
+def formatted_plain_text(formatted_text) -> str:
+ return "".join(fragment for _, fragment in list(formatted_text))
+
+
@pytest.fixture(autouse=True)
def clean_registry():
er.clear()
@@ -17,12 +91,10 @@ def clean_registry():
er.clear()
-# --- Dummy Action ---
async def dummy_action():
return "ok"
-# --- Dummy IO Action ---
class DummyInputAction(BaseIOAction):
async def _run(self, *args, **kwargs):
return "needs input"
@@ -31,7 +103,6 @@ class DummyInputAction(BaseIOAction):
pass
-# --- Tests ---
@pytest.mark.asyncio
async def test_command_creation():
"""Test if Command can be created with a callable."""
@@ -172,3 +243,654 @@ 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)
+
+
+@pytest.mark.asyncio
+async def test_resolve_args_uses_custom_parser_and_splits_string_input() -> None:
+ seen: list[list[str]] = []
+
+ def custom_parser(tokens: list[str]):
+ seen.append(tokens)
+ return (("parsed",), {"tokens": tokens}, {"summary": True})
+
+ command = make_command(custom_parser=custom_parser)
+
+ args, kwargs, execution_args = await command.resolve_args("--name 'Ada Lovelace'")
+
+ assert seen == [["--name", "Ada Lovelace"]]
+ assert args == ("parsed",)
+ assert kwargs == {"tokens": ["--name", "Ada Lovelace"]}
+ assert execution_args == {"summary": True}
+
+
+@pytest.mark.asyncio
+async def test_resolve_args_rejects_non_callable_custom_parser() -> None:
+ command = make_command(custom_parser=lambda tokens: ((), {}, {}))
+ command.custom_parser = object()
+
+ with pytest.raises(NotAFalyxError, match="custom_parser must be a callable"):
+ await command.resolve_args([])
+
+
+@pytest.mark.asyncio
+async def test_resolve_args_wraps_bad_shell_input_for_custom_parser() -> None:
+ command = make_command(custom_parser=lambda tokens: ((), {}, {}))
+
+ with pytest.raises(CommandArgumentError, match="Failed to parse arguments"):
+ await command.resolve_args("'unterminated")
+
+
+@pytest.mark.asyncio
+async def test_resolve_args_wraps_bad_shell_input_for_command_argument_parser() -> None:
+ command = make_command()
+
+ with pytest.raises(CommandArgumentError, match="Failed to parse arguments"):
+ await command.resolve_args("'unterminated")
+
+
+@pytest.mark.asyncio
+async def test_resolve_args_rejects_missing_parser_when_no_custom_parser_exists() -> None:
+ command = make_command(custom_parser=lambda tokens: ((), {}, {}))
+ command.custom_parser = None
+ command.arg_parser = None
+
+ with pytest.raises(NotAFalyxError, match="Command has no parser configured"):
+ await command.resolve_args([])
+
+
+@pytest.mark.asyncio
+async def test_resolve_args_rejects_invalid_arg_parser_instance() -> None:
+ command = make_command()
+ command.arg_parser = object()
+
+ with pytest.raises(NotAFalyxError, match="arg_parser must be an instance"):
+ await command.resolve_args([])
+
+
+@pytest.mark.asyncio
+async def test_explicit_argument_definitions_are_added_to_default_parser() -> None:
+ command = make_command(
+ arguments=[
+ {
+ "flags": ("target",),
+ "help": "Deployment target",
+ },
+ {
+ "flags": ("--region",),
+ "default": "us-east",
+ },
+ ]
+ )
+
+ args, kwargs, execution_args = await command.resolve_args(
+ ["api", "--region", "us-west"]
+ )
+
+ assert args == ("api",)
+ assert kwargs == {"region": "us-west"}
+ assert execution_args == {}
+
+
+@pytest.mark.asyncio
+async def test_argument_config_callback_configures_existing_parser() -> None:
+ def configure(parser: CommandArgumentParser) -> None:
+ parser.add_argument("--region", default="us-east")
+
+ command = make_command(argument_config=configure)
+
+ args, kwargs, execution_args = await command.resolve_args(["--region", "us-west"])
+
+ assert args == ()
+ assert kwargs == {"region": "us-west"}
+ assert execution_args == {}
+
+
+def test_base_action_inference_merges_action_metadata() -> None:
+ def deploy(region: str) -> None:
+ return None
+
+ action = FakeBaseAction(
+ infer_target=deploy,
+ metadata={"region": {"help": "Region from action metadata"}},
+ )
+
+ command = Command.build(
+ key="D",
+ description="Deploy command",
+ action=action,
+ auto_args=True,
+ )
+
+ assert command.arg_metadata["region"] == {"help": "Region from action metadata"}
+ assert isinstance(command.arg_parser, CommandArgumentParser)
+ assert "region" in command.arg_parser._positional
+
+
+def test_build_validates_parser_runtime_dependencies_and_retry_policy() -> None:
+ with pytest.raises(NotAFalyxError, match="arg_parser"):
+ make_command(arg_parser=object())
+
+ with pytest.raises(NotAFalyxError, match="options_manager"):
+ make_command(options_manager=object())
+
+ with pytest.raises(InvalidHookError, match="HookManager"):
+ make_command(hooks=object())
+
+ with pytest.raises(NotAFalyxError, match="retry_policy"):
+ make_command(retry_policy=object())
+
+
+def test_build_normalizes_execution_options_and_registers_hook_lists() -> None:
+ async def before(_context) -> None:
+ return None
+
+ async def success(_context) -> None:
+ return None
+
+ async def error(_context) -> None:
+ return None
+
+ async def after(_context) -> None:
+ return None
+
+ async def teardown(_context) -> None:
+ return None
+
+ command = make_command(
+ execution_options=["summary", ExecutionOption.CONFIRM],
+ before_hooks=[before],
+ success_hooks=[success],
+ error_hooks=[error],
+ after_hooks=[after],
+ teardown_hooks=[teardown],
+ spinner=True,
+ )
+
+ assert ExecutionOption.SUMMARY in command.execution_options
+ assert ExecutionOption.CONFIRM in command.execution_options
+ assert before in command.hooks._hooks[HookType.BEFORE]
+ assert success in command.hooks._hooks[HookType.ON_SUCCESS]
+ assert error in command.hooks._hooks[HookType.ON_ERROR]
+ assert after in command.hooks._hooks[HookType.AFTER]
+ assert teardown in command.hooks._hooks[HookType.ON_TEARDOWN]
+ assert command.hooks._hooks[HookType.BEFORE]
+ assert command.hooks._hooks[HookType.ON_TEARDOWN]
+
+
+def test_model_post_init_warns_for_retry_flags_on_plain_callable(
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ with caplog.at_level(logging.WARNING):
+ make_command(retry=True, retry_all=True)
+
+ assert "Retry requested" in caplog.text
+ assert "Retry all requested" in caplog.text
+
+
+def test_retry_all_for_base_action_enables_policy_recursively(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ action = FakeBaseAction()
+ calls: list[tuple[BaseAction, RetryPolicy]] = []
+
+ def fake_enable_retries_recursively(
+ base_action: BaseAction, policy: RetryPolicy
+ ) -> None:
+ calls.append((base_action, policy))
+
+ monkeypatch.setattr(
+ command_module,
+ "enable_retries_recursively",
+ fake_enable_retries_recursively,
+ )
+
+ command = Command.build(
+ key="D",
+ description="Deploy command",
+ action=action,
+ retry_all=True,
+ auto_args=False,
+ )
+
+ assert command.retry_policy.enabled is True
+ assert calls == [(action, command.retry_policy)]
+
+
+def test_logging_hooks_are_registered_on_base_action() -> None:
+ action = FakeBaseAction()
+
+ Command.build(
+ key="D",
+ description="Deploy command",
+ action=action,
+ logging_hooks=True,
+ auto_args=False,
+ )
+
+ assert any(action.hooks._hooks.values())
+
+
+def test_ignore_in_history_is_copied_to_base_action() -> None:
+ action = FakeBaseAction()
+
+ Command.build(
+ key="D",
+ description="Deploy command",
+ action=action,
+ ignore_in_history=True,
+ auto_args=False,
+ )
+
+ assert action.ignore_in_history is True
+
+
+def test_retry_flag_enables_retry_on_action_instance() -> None:
+ action = Action("DeployAction", lambda: "ok")
+
+ Command.build(
+ key="D",
+ description="Deploy command",
+ action=action,
+ retry=True,
+ auto_args=False,
+ )
+
+ assert action.retry_policy.enabled is True
+
+
+def test_confirmation_prompt_uses_custom_message() -> None:
+ command = make_command(confirm_message="Ship it?")
+
+ assert list(command._confirmation_prompt) == [("class:confirm", "Ship it?")]
+
+
+def test_confirmation_prompt_describes_default_callable_with_static_inputs() -> None:
+ def deploy() -> str:
+ return "ok"
+
+ command = Command.build(
+ key="D",
+ description="Deploy command",
+ action=deploy,
+ args=("api",),
+ kwargs={"region": "us-east"},
+ auto_args=False,
+ )
+
+ plain_text = formatted_plain_text(command._confirmation_prompt)
+
+ assert "Confirm execution of" in plain_text
+ assert "D" in plain_text
+ assert "Deploy command" in plain_text
+ assert "calls" in plain_text
+ assert "args=('api',)" in plain_text
+ assert "kwargs={'region': 'us-east'}" in plain_text
+
+
+def test_confirmation_prompt_uses_base_action_name() -> None:
+ command = Command.build(
+ key="D",
+ description="Deploy command",
+ action=FakeBaseAction("DeployAction"),
+ auto_args=False,
+ )
+
+ assert "DeployAction" in formatted_plain_text(command._confirmation_prompt)
+
+
+@pytest.mark.asyncio
+async def test_confirmation_cancel_previews_then_raises_cancel_signal(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ command = make_command(confirm=True, preview_before_confirm=True)
+ previewed: list[str] = []
+ confirmed_prompts: list[Any] = []
+
+ async def fake_preview(self: Command) -> None:
+ previewed.append(self.key)
+
+ async def fake_confirm(prompt) -> bool:
+ confirmed_prompts.append(prompt)
+ return False
+
+ monkeypatch.setattr(Command, "preview", fake_preview)
+ monkeypatch.setattr(command_module, "confirm_async", fake_confirm)
+
+ with pytest.raises(CancelSignal, match="Cancelled by confirmation"):
+ await command()
+
+ assert previewed == ["D"]
+ assert confirmed_prompts
+
+
+@pytest.mark.asyncio
+async def test_confirmation_accepts_and_executes_action(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ calls: list[str] = []
+
+ async def fake_confirm(_prompt) -> bool:
+ return True
+
+ def action() -> str:
+ calls.append("ran")
+ return "done"
+
+ monkeypatch.setattr(command_module, "confirm_async", fake_confirm)
+ command = Command.build(
+ key="D",
+ description="Deploy command",
+ action=action,
+ confirm=True,
+ preview_before_confirm=False,
+ auto_args=False,
+ )
+
+ assert await command() == "done"
+ assert calls == ["ran"]
+
+
+def test_get_option_returns_default_when_no_options_manager_is_available() -> None:
+ command = make_command()
+ command.options_manager = None
+
+ assert command.get_option("missing", "fallback") == "fallback"
+
+
+def test_primary_alias_falls_back_to_command_key() -> None:
+ assert make_command(aliases=[]).primary_alias == "D"
+
+
+def test_usage_reports_no_arguments_when_parser_is_absent() -> None:
+ command = make_command(custom_parser=lambda tokens: ((), {}, {}))
+
+ assert command.usage == "No arguments defined."
+
+
+def test_usage_delegates_to_arg_parser_when_available() -> None:
+ command = make_command(aliases=["deploy"])
+
+ assert "D" in command.usage
+ assert "deploy" in command.usage
+
+
+def test_help_signature_full_mode_includes_help_text_and_tags() -> None:
+ command = make_command(help_text="Detailed deploy help", tags=["deploy", "cloud"])
+
+ usage, description, tags = command.help_signature
+
+ assert "D" in usage
+ assert "Detailed deploy help" in description
+ assert "deploy, cloud" in tags
+
+
+def test_help_signature_simple_mode_uses_key_and_aliases() -> None:
+ command = make_command(
+ aliases=["deploy"],
+ help_text="Detailed deploy help",
+ simple_help_signature=True,
+ )
+
+ usage, description, tags = command.help_signature
+
+ assert "D" in usage
+ assert "deploy" in usage
+ assert "Detailed deploy help" in description
+ assert tags == ""
+
+
+def test_log_summary_delegates_to_existing_context() -> None:
+ command = make_command()
+ calls: list[str] = []
+ command._context = SimpleNamespace(log_summary=lambda: calls.append("logged"))
+
+ command.log_summary()
+
+ assert calls == ["logged"]
+
+
+def test_render_usage_prefers_custom_usage(monkeypatch: pytest.MonkeyPatch) -> None:
+ captured = CaptureConsole()
+ monkeypatch.setattr(command_module, "console", captured)
+ command = make_command(custom_usage=lambda: "custom usage")
+
+ command.render_usage()
+
+ assert captured.printed[0][0] == ("custom usage",)
+
+
+def test_render_usage_falls_back_to_command_key_without_parser(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ captured = CaptureConsole()
+ monkeypatch.setattr(command_module, "console", captured)
+ command = make_command(custom_parser=lambda tokens: ((), {}, {}))
+
+ command.render_usage()
+
+ assert captured.printed[0][0] == ("[bold]usage:[/] D",)
+
+
+def test_render_help_and_tldr_custom_renderers_return_true(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ captured = CaptureConsole()
+ monkeypatch.setattr(command_module, "console", captured)
+ command = make_command(
+ custom_help=lambda: "custom help",
+ custom_tldr=lambda: "custom tldr",
+ )
+
+ assert command.render_help() is True
+ assert command.render_tldr() is True
+ assert [printed[0][0] for printed in captured.printed] == [
+ "custom help",
+ "custom tldr",
+ ]
+
+
+def test_render_help_and_tldr_return_false_without_parser_or_custom_renderer() -> None:
+ command = make_command(custom_parser=lambda tokens: ((), {}, {}))
+
+ assert command.render_help() is False
+ assert command.render_tldr() is False
+
+
+@pytest.mark.asyncio
+async def test_preview_renders_plain_callable_details(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ captured = CaptureConsole()
+ monkeypatch.setattr(command_module, "console", captured)
+
+ def deploy() -> str:
+ return "ok"
+
+ command = Command.build(
+ key="D",
+ description="Deploy command",
+ action=deploy,
+ args=("api",),
+ kwargs={"region": "us-east"},
+ help_text="Preview help",
+ auto_args=False,
+ )
+
+ await command.preview()
+
+ rendered = "\n".join(str(args[0]) for args, _ in captured.printed)
+ assert "Command:" in rendered
+ assert "Preview help" in rendered
+ assert "Would call:" in rendered
+ assert "args=('api',), kwargs={'region': 'us-east'}" in rendered
+
+
+@pytest.mark.asyncio
+async def test_preview_renders_base_action_tree(monkeypatch: pytest.MonkeyPatch) -> None:
+ captured = CaptureConsole()
+ monkeypatch.setattr(command_module, "console", captured)
+ action = FakeBaseAction("DeployAction")
+ command = Command.build(
+ key="D",
+ description="Deploy command",
+ action=action,
+ help_text="Preview help",
+ auto_args=False,
+ )
+
+ await command.preview()
+
+ assert action.preview_calls == 1
+ assert captured.printed
+
+
+@pytest.mark.asyncio
+async def test_call_merges_static_and_invocation_inputs_and_triggers_hooks() -> None:
+ events: list[tuple[str, Any]] = []
+
+ async def before(context) -> None:
+ events.append(("before", context.args))
+
+ async def success(context) -> None:
+ events.append(("success", context.result))
+
+ async def after(context) -> None:
+ events.append(("after", context.result))
+
+ async def teardown(context) -> None:
+ events.append(("teardown", context.result))
+
+ def action(*args: Any, **kwargs: Any) -> dict[str, Any]:
+ return {"args": args, "kwargs": kwargs}
+
+ command = Command.build(
+ key="D",
+ description="Deploy command",
+ action=action,
+ args=("static",),
+ kwargs={"region": "us-east"},
+ before_hooks=[before],
+ success_hooks=[success],
+ after_hooks=[after],
+ teardown_hooks=[teardown],
+ auto_args=False,
+ )
+
+ result = await command("runtime", region="us-west")
+
+ assert result == {
+ "args": ("runtime", "static"),
+ "kwargs": {"region": "us-west"},
+ }
+ assert command.result == result
+ assert events == [
+ ("before", ("runtime", "static")),
+ ("success", result),
+ ("after", result),
+ ("teardown", result),
+ ]
+
+
+@pytest.mark.asyncio
+async def test_call_triggers_error_after_and_teardown_hooks_on_failure() -> None:
+ events: list[tuple[str, str | None]] = []
+
+ async def on_error(context) -> None:
+ events.append(("error", str(context.exception)))
+
+ async def after(context) -> None:
+ events.append(("after", str(context.exception)))
+
+ async def teardown(context) -> None:
+ events.append(("teardown", str(context.exception)))
+
+ def action() -> None:
+ raise RuntimeError("boom")
+
+ command = Command.build(
+ key="D",
+ description="Deploy command",
+ action=action,
+ error_hooks=[on_error],
+ after_hooks=[after],
+ teardown_hooks=[teardown],
+ auto_args=False,
+ )
+
+ with pytest.raises(RuntimeError, match="boom"):
+ await command()
+
+ assert events == [
+ ("error", "boom"),
+ ("after", "boom"),
+ ("teardown", "boom"),
+ ]
+
+
+def test_str_includes_command_identity() -> None:
+ text = str(make_command())
+
+ assert "Command(key='D'" in text
+ assert "Deploy command" in text
+
+
+def test_clone_with_overrides_clones_parser_hooks_and_base_action() -> None:
+ action = FakeBaseAction("DeployAction")
+
+ async def before(_context) -> None:
+ return None
+
+ command = Command.build(
+ key="D",
+ description="Deploy command",
+ action=action,
+ aliases=["deploy"],
+ before_hooks=[before],
+ auto_args=False,
+ )
+
+ clone = command.clone_with_overrides(
+ key="P",
+ description="Promote command",
+ aliases=["promote"],
+ )
+
+ assert clone.key == "P"
+ assert clone.description == "Promote command"
+ assert clone.aliases == ["promote"]
+ assert clone.action is not command.action
+ assert isinstance(clone.action, FakeBaseAction)
+ assert clone.hooks is not command.hooks
+ assert before in clone.hooks._hooks[HookType.BEFORE]
+ assert isinstance(clone.arg_parser, CommandArgumentParser)
+ assert clone.arg_parser.command_key == "P"
+
+
+def test_clone_with_overrides_can_replace_action_and_execution_options() -> None:
+ command = make_command(execution_options=["summary"])
+
+ def replacement() -> str:
+ return "replacement"
+
+ clone = command.clone_with_overrides(
+ action=replacement,
+ execution_options=[ExecutionOption.CONFIRM],
+ simple_help_signature=True,
+ )
+
+ assert clone.action is not command.action
+ assert ExecutionOption.CONFIRM in clone.execution_options
+ assert ExecutionOption.SUMMARY not in clone.execution_options
+ assert clone.simple_help_signature is True
diff --git a/tests/test_completer/test_completer.py b/tests/test_completer/test_completer.py
index e5764ec..d6d379f 100644
--- a/tests/test_completer/test_completer.py
+++ b/tests/test_completer/test_completer.py
@@ -1,97 +1,305 @@
-from types import SimpleNamespace
+import re
import pytest
from prompt_toolkit.completion import Completion
from prompt_toolkit.document import Document
+from falyx import Falyx
from falyx.completer import FalyxCompleter
+from falyx.parser import CommandArgumentParser
+
+
+def completion_texts(completions) -> list[str]:
+ return [c.text for c in completions]
@pytest.fixture
-def fake_falyx():
- fake_arg_parser = SimpleNamespace(
- suggest_next=lambda tokens, end: ["--tag", "--name", "value with space"]
+def falyx():
+ flx = Falyx()
+
+ run_parser = CommandArgumentParser(
+ command_key="R",
+ command_description="Run Command",
)
- fake_command = SimpleNamespace(key="R", aliases=["RUN"], arg_parser=fake_arg_parser)
- return SimpleNamespace(
- exit_command=SimpleNamespace(key="X", aliases=["EXIT"]),
- help_command=SimpleNamespace(key="H", aliases=["HELP"]),
- history_command=SimpleNamespace(key="Y", aliases=["HISTORY"]),
- commands={"R": fake_command},
- _name_map={"R": fake_command, "RUN": fake_command, "X": fake_command},
+ run_parser.add_argument("--tag")
+ run_parser.add_argument("--name")
+
+ flx.add_command(
+ "R",
+ "Run Command",
+ lambda: None,
+ aliases=["RUN"],
+ arg_parser=run_parser,
)
+ ops = Falyx(program="ops")
-def test_suggest_commands(fake_falyx):
- completer = FalyxCompleter(fake_falyx)
- completions = list(completer._suggest_commands("R"))
- assert any(c.text == "R" for c in completions)
- assert any(c.text == "RUN" for c in completions)
+ deploy_parser = CommandArgumentParser(
+ command_key="D",
+ command_description="Deploy Command",
+ )
+ deploy_parser.add_argument("--target")
+ deploy_parser.add_argument("--region")
+
+ ops.add_command(
+ "D",
+ "Deploy Command",
+ lambda: None,
+ aliases=["DEPLOY"],
+ arg_parser=deploy_parser,
+ )
+
+ flx.add_submenu(
+ "OPS",
+ "Operations",
+ ops,
+ aliases=["OPERATIONS"],
+ )
+
+ return flx
-def test_suggest_commands_empty(fake_falyx):
- completer = FalyxCompleter(fake_falyx)
- completions = list(completer._suggest_commands(""))
- assert any(c.text == "X" for c in completions)
- assert any(c.text == "H" for c in completions)
+def test_suggest_namespace_entries_root(falyx):
+ completer = FalyxCompleter(falyx)
+
+ completions = completer._suggest_namespace_entries(falyx, "R")
+
+ assert "R" in completions
+ assert "RUN" in completions
+
+ completions = completer._suggest_namespace_entries(falyx, "r")
+
+ assert "r" in completions
+ assert "run" in completions
-def test_suggest_commands_no_match(fake_falyx):
- completer = FalyxCompleter(fake_falyx)
- completions = list(completer._suggest_commands("Z"))
- assert not completions
+def test_suggest_namespace_entries_submenu(falyx):
+ completer = FalyxCompleter(falyx)
+ ops = falyx.namespaces["OPS"].namespace
+
+ completions = completer._suggest_namespace_entries(ops, "D")
+
+ assert "D" in completions
+ assert "DEPLOY" in completions
-def test_get_completions_no_input(fake_falyx):
- completer = FalyxCompleter(fake_falyx)
- doc = Document("")
- results = list(completer.get_completions(doc, None))
+def test_get_completions_no_input_shows_root_entries(falyx):
+ completer = FalyxCompleter(falyx)
+
+ results = list(completer.get_completions(Document(""), None))
+ texts = completion_texts(results)
+
assert any(isinstance(c, Completion) for c in results)
- assert any(c.text == "X" for c in results)
+ assert "R" in texts
+ assert "OPS" in texts
+ assert "X" in texts
-def test_get_completions_no_match(fake_falyx):
- completer = FalyxCompleter(fake_falyx)
- doc = Document("Z")
- completions = list(completer.get_completions(doc, None))
- assert not completions
- doc = Document("Z Z")
- completions = list(completer.get_completions(doc, None))
- assert not completions
+def test_get_completions_partial_root_entry(falyx):
+ completer = FalyxCompleter(falyx)
+
+ results = list(completer.get_completions(Document("OP"), None))
+ texts = completion_texts(results)
+
+ assert "OPS" in texts
+ assert "OPERATIONS" in texts
-def test_get_completions_partial_command(fake_falyx):
- completer = FalyxCompleter(fake_falyx)
- doc = Document("R")
- results = list(completer.get_completions(doc, None))
- assert any(c.text in ("R", "RUN") for c in results)
+def test_get_completions_no_match_returns_empty(falyx):
+ completer = FalyxCompleter(falyx)
+
+ assert list(completer.get_completions(Document("Z"), None)) == []
+ assert list(completer.get_completions(Document("OPS Z"), None)) == []
-def test_get_completions_with_flag(fake_falyx):
- completer = FalyxCompleter(fake_falyx)
- doc = Document("R ")
- results = list(completer.get_completions(doc, None))
- assert "--tag" in [c.text for c in results]
+def test_get_completions_namespace_boundary_suggests_help_flags(falyx):
+ completer = FalyxCompleter(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
+ assert "--tldr" in texts
-def test_get_completions_partial_flag(fake_falyx):
- completer = FalyxCompleter(fake_falyx)
- doc = Document("R --t")
- results = list(completer.get_completions(doc, None))
- assert all(c.start_position <= 0 for c in results)
- assert any(c.text.startswith("--t") or c.display == "--tag" for c in results)
+def test_get_completions_preview_prefix_is_preserved(falyx):
+ completer = FalyxCompleter(falyx)
+
+ results = list(completer.get_completions(Document("?R"), None))
+ texts = completion_texts(results)
+
+ assert any(text.startswith("?R") for text in texts)
-def test_get_completions_bad_input(fake_falyx):
- completer = FalyxCompleter(fake_falyx)
- doc = Document('R "unclosed quote')
- results = list(completer.get_completions(doc, None))
+def test_get_completions_preview_prefix_for_namespace_entries(falyx):
+ completer = FalyxCompleter(falyx)
+
+ results = list(completer.get_completions(Document("?OP"), None))
+ texts = completion_texts(results)
+
+ assert "?OPS" in texts or "?OPERATIONS" in texts
+
+
+def test_get_completions_leaf_command_delegates_flags_to_root_command_parser(
+ falyx, monkeypatch
+):
+ completer = FalyxCompleter(falyx)
+
+ seen = {}
+
+ def fake_suggest_next(args, cursor_at_end_of_token):
+ seen["args"] = list(args)
+ seen["cursor_at_end_of_token"] = cursor_at_end_of_token
+ return ["--tag"]
+
+ monkeypatch.setattr(
+ falyx.commands["R"].arg_parser,
+ "suggest_next",
+ fake_suggest_next,
+ )
+
+ results = list(completer.get_completions(Document("R --t"), None))
+ texts = completion_texts(results)
+
+ assert seen["args"] == ["--t"]
+ assert seen["cursor_at_end_of_token"] is False
+ assert "--tag" in texts
+
+
+def test_get_completions_leaf_command_delegates_flags_to_submenu_command_parser(
+ falyx, monkeypatch
+):
+ completer = FalyxCompleter(falyx)
+ ops = falyx.namespaces["OPS"].namespace
+ deploy = ops.commands["D"]
+
+ seen = {}
+
+ def fake_suggest_next(args, cursor_at_end_of_token):
+ seen["args"] = list(args)
+ seen["cursor_at_end_of_token"] = cursor_at_end_of_token
+ return ["--target"]
+
+ monkeypatch.setattr(
+ deploy.arg_parser,
+ "suggest_next",
+ fake_suggest_next,
+ )
+
+ results = list(completer.get_completions(Document("OPS D --t"), None))
+ texts = completion_texts(results)
+
+ assert seen["args"] == ["--t"]
+ assert seen["cursor_at_end_of_token"] is False
+ assert "--target" in texts
+
+
+def test_get_completions_leaf_command_receives_empty_stub_after_space(falyx, monkeypatch):
+ completer = FalyxCompleter(falyx)
+
+ seen = {}
+
+ def fake_suggest_next(args, cursor_at_end_of_token):
+ seen["args"] = list(args)
+ seen["cursor_at_end_of_token"] = cursor_at_end_of_token
+ return ["--tag", "--name"]
+
+ monkeypatch.setattr(
+ falyx.commands["R"].arg_parser,
+ "suggest_next",
+ fake_suggest_next,
+ )
+
+ results = list(completer.get_completions(Document("R "), None))
+ texts = completion_texts(results)
+
+ assert seen["args"] == []
+ assert seen["cursor_at_end_of_token"] is True
+ assert "--tag" in texts
+ assert "--name" in texts
+
+
+def test_get_completions_bad_input(falyx):
+ completer = FalyxCompleter(falyx)
+
+ results = list(completer.get_completions(Document('R "unclosed quote'), None))
+
assert results == []
-def test_get_completions_exception_handling(fake_falyx):
- completer = FalyxCompleter(fake_falyx)
- fake_falyx.commands["R"].arg_parser.suggest_next = lambda *args: 1 / 0
- doc = Document("R --tag")
- results = list(completer.get_completions(doc, None))
+def test_get_completions_exception_handling(falyx, monkeypatch):
+ completer = FalyxCompleter(falyx)
+
+ def boom(*args, **kwargs):
+ raise ZeroDivisionError("boom")
+
+ monkeypatch.setattr(falyx.commands["R"].arg_parser, "suggest_next", boom)
+
+ results = list(completer.get_completions(Document("R --tag"), None))
+
assert results == []
+
+
+def test_ensure_quote_wraps_whitespace(falyx):
+ completer = FalyxCompleter(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
diff --git a/tests/test_completer/test_lcp_completions.py b/tests/test_completer/test_lcp_completions.py
index 9a9f1a1..cb2a895 100644
--- a/tests/test_completer/test_lcp_completions.py
+++ b/tests/test_completer/test_lcp_completions.py
@@ -1,38 +1,42 @@
from types import SimpleNamespace
import pytest
-from prompt_toolkit.document import Document
from falyx.completer import FalyxCompleter
-@pytest.fixture
-def fake_falyx():
- fake_arg_parser = SimpleNamespace(
- suggest_next=lambda tokens, end: ["AETHERWARP", "AETHERZOOM"]
- )
- fake_command = SimpleNamespace(key="R", aliases=["RUN"], arg_parser=fake_arg_parser)
- return SimpleNamespace(
- exit_command=SimpleNamespace(key="X", aliases=["EXIT"]),
- help_command=SimpleNamespace(key="H", aliases=["HELP"]),
- history_command=SimpleNamespace(key="Y", aliases=["HISTORY"]),
- commands={"R": fake_command},
- _name_map={"R": fake_command, "RUN": fake_command, "X": fake_command},
- )
+def completion_texts(completions) -> list[str]:
+ return [c.text for c in completions]
-def test_lcp_completions(fake_falyx):
- completer = FalyxCompleter(fake_falyx)
- doc = Document("R A")
- results = list(completer.get_completions(doc, None))
- assert any(c.text == "AETHER" for c in results)
- assert any(c.text == "AETHERWARP" for c in results)
- assert any(c.text == "AETHERZOOM" for c in results)
+def test_lcp_completions():
+ completer = FalyxCompleter(SimpleNamespace())
+ suggestions = ["AETHERWARP", "AETHERZOOM"]
+ stub = "A"
+ completions = list(completer._yield_lcp_completions(suggestions, stub))
+ texts = completion_texts(completions)
+
+ assert "AETHER" in texts
+ assert "AETHERWARP" in texts
+ assert "AETHERZOOM" in texts
-def test_lcp_completions_space(fake_falyx):
- completer = FalyxCompleter(fake_falyx)
+def test_lcp_completions_space():
+ completer = FalyxCompleter(SimpleNamespace())
suggestions = ["London", "New York", "San Francisco"]
stub = "N"
completions = list(completer._yield_lcp_completions(suggestions, stub))
- assert any(c.text == '"New York"' for c in completions)
+ texts = completion_texts(completions)
+ assert '"New York"' in texts
+
+
+def test_lcp_completions_does_not_collapse_flags():
+ completer = FalyxCompleter(SimpleNamespace())
+ suggestions = ["--tag", "--target"]
+ stub = "--t"
+ completions = list(completer._yield_lcp_completions(suggestions, stub))
+ texts = completion_texts(completions)
+
+ assert "--tag" in texts
+ assert "--target" in texts
+ assert "--ta" not in texts
diff --git a/tests/test_context.py b/tests/test_context.py
new file mode 100644
index 0000000..a58e8b2
--- /dev/null
+++ b/tests/test_context.py
@@ -0,0 +1,341 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any
+
+import pytest
+from rich.console import Console
+
+from falyx.context import ExecutionContext, InvocationContext, SharedContext
+from falyx.mode import FalyxMode
+
+
+class DummyAction:
+ def __init__(self, name: str = "DummyAction") -> None:
+ self.name = name
+
+ def __str__(self) -> str:
+ return self.name
+
+
+def make_execution_context(**overrides: Any) -> ExecutionContext:
+ defaults: dict[str, Any] = {
+ "name": "Build",
+ "action": DummyAction("build"),
+ }
+ defaults.update(overrides)
+ return ExecutionContext(**defaults)
+
+
+def make_shared_context(**overrides: Any) -> SharedContext:
+ defaults: dict[str, Any] = {
+ "name": "Workflow",
+ "action": DummyAction("workflow"),
+ }
+ defaults.update(overrides)
+ return SharedContext(**defaults)
+
+
+def test_execution_context_get_shared_context_returns_existing_context() -> None:
+ shared = make_shared_context()
+ context = make_execution_context(shared_context=shared)
+
+ assert context.get_shared_context() is shared
+
+
+def test_execution_context_get_shared_context_raises_when_missing() -> None:
+ context = make_execution_context()
+
+ with pytest.raises(ValueError, match="SharedContext is not set"):
+ context.get_shared_context()
+
+
+def test_execution_context_duration_handles_not_started_running_and_stopped(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ context = make_execution_context()
+ assert context.duration is None
+
+ context.start_time = 10.0
+ context.end_time = None
+ monkeypatch.setattr("falyx.context.time.perf_counter", lambda: 12.5)
+ assert context.duration == pytest.approx(2.5)
+
+ context.end_time = 14.0
+ assert context.duration == pytest.approx(4.0)
+
+
+def test_execution_context_start_and_stop_timer_populate_timer_fields() -> None:
+ context = make_execution_context()
+
+ context.start_timer()
+ assert context.start_wall is not None
+ assert context.start_time is not None
+
+ context.stop_timer()
+ assert context.end_wall is not None
+ assert context.end_time is not None
+ assert context.duration is not None
+ assert context.duration >= 0
+
+
+def test_execution_context_exception_setter_records_traceback_and_status() -> None:
+ context = make_execution_context(result="ignored after failure")
+
+ context.exception = RuntimeError("boom")
+
+ assert isinstance(context.exception, RuntimeError)
+ assert context.success is False
+ assert context.status == "ERROR"
+ assert context.traceback is not None
+ assert "RuntimeError: boom" in context.traceback
+
+
+def test_execution_context_as_dict_includes_result_exception_traceback_duration_and_extra() -> (
+ None
+):
+ context = make_execution_context(
+ result={"artifact": "dist/app.whl"},
+ start_time=2.0,
+ end_time=5.25,
+ extra={"attempt": 2},
+ )
+ context.exception = ValueError("invalid build")
+
+ summary = context.as_dict()
+
+ assert summary["name"] == "Build"
+ assert summary["result"] == {"artifact": "dist/app.whl"}
+ assert summary["exception"] == "ValueError('invalid build')"
+ assert "ValueError: invalid build" in summary["traceback"]
+ assert summary["duration"] == pytest.approx(3.25)
+ assert summary["extra"] == {"attempt": 2}
+
+
+def test_execution_context_signature_formats_args_and_kwargs() -> None:
+ context = make_execution_context(args=("src", 3), kwargs={"verbose": True})
+
+ assert context.signature == "build ('src', 3, verbose=True)"
+
+
+def test_execution_context_log_summary_prints_success_to_context_console() -> None:
+ recording_console = Console(record=True, width=160)
+ context = make_execution_context(
+ result="ok",
+ start_time=1.0,
+ end_time=2.5,
+ start_wall=datetime(2026, 6, 7, 11, 0, 0),
+ end_wall=datetime(2026, 6, 7, 11, 0, 2),
+ console=recording_console,
+ )
+
+ context.log_summary()
+
+ output = recording_console.export_text()
+ assert "[SUMMARY] Build" in output
+ assert "Start: 11:00:00" in output
+ assert "End: 11:00:02" in output
+ assert "Duration: 1.500s" in output
+ assert "Result: ok" in output
+
+
+def test_execution_context_log_summary_uses_logger_and_includes_exception() -> None:
+ messages: list[str] = []
+ context = make_execution_context(
+ result="unused",
+ start_time=10.0,
+ end_time=11.0,
+ )
+ context.exception = OSError("disk full")
+
+ context.log_summary(logger=messages.append)
+
+ assert len(messages) == 1
+ assert "[SUMMARY] Build" in messages[0]
+ assert "Duration: 1.000s" in messages[0]
+ assert "Exception: OSError('disk full')" in messages[0]
+
+
+def test_execution_context_to_log_line_renders_success_and_error_states() -> None:
+ success = make_execution_context(result="ok", start_time=1.0, end_time=1.5)
+ failure = make_execution_context(result=None, start_time=2.0, end_time=3.0)
+ failure.exception = LookupError("missing")
+
+ assert success.to_log_line() == (
+ "[Build] status=OK duration=0.500s result='ok' exception=None"
+ )
+ assert failure.to_log_line() == (
+ "[Build] status=ERROR duration=1.000s result=None "
+ "exception=LookupError: missing"
+ )
+
+
+def test_execution_context_str_and_repr_render_success_with_no_duration() -> None:
+ context = make_execution_context(result=["ok"])
+
+ text = str(context)
+ debug = repr(context)
+
+ assert " None:
+ context = make_execution_context(start_time=1.0, end_time=1.75)
+ context.exception = RuntimeError("failed")
+
+ text = str(context)
+ debug = repr(context)
+
+ assert " None:
+ shared = make_shared_context()
+ error = RuntimeError("step failed")
+
+ shared.add_result("first")
+ shared.add_error(1, error)
+ shared.set("artifact", "dist/app.whl")
+
+ assert shared.results == ["first"]
+ assert shared.errors == [(1, error)]
+ assert shared.get("artifact") == "dist/app.whl"
+ assert shared.get("missing", "default") == "default"
+ assert shared.last_result() == "first"
+
+
+def test_shared_context_last_result_returns_none_when_sequential_context_has_no_results() -> (
+ None
+):
+ shared = make_shared_context()
+
+ assert shared.last_result() is None
+
+
+def test_shared_context_set_shared_result_does_not_append_for_sequential_context() -> (
+ None
+):
+ shared = make_shared_context(is_concurrent=False)
+
+ shared.set_shared_result("shared-value")
+
+ assert shared.shared_result == "shared-value"
+ assert shared.results == []
+ assert shared.last_result() is None
+
+
+def test_shared_context_set_shared_result_appends_and_reads_from_concurrent_context() -> (
+ None
+):
+ shared = make_shared_context(is_concurrent=True)
+
+ shared.set_shared_result("group-value")
+
+ assert shared.shared_result == "group-value"
+ assert shared.results == ["group-value"]
+ assert shared.last_result() == "group-value"
+
+
+def test_shared_context_str_marks_sequential_and_concurrent_modes() -> None:
+ sequential = make_shared_context(results=["a"])
+ concurrent = make_shared_context(is_concurrent=True, results=["b"])
+
+ assert " None:
+ root = InvocationContext(program="falyx", mode=FalyxMode.MENU)
+ one = root.with_path_segment("admin", style="cyan")
+ two = one.with_path_segment("deploy", style="green")
+ trimmed = two.without_last_path_segment()
+
+ assert root.typed_path == []
+ assert root.segments == []
+ assert one.typed_path == ["admin"]
+ assert one.segments[0].text == "admin"
+ assert str(one.segments[0].style) == "cyan"
+ assert two.typed_path == ["admin", "deploy"]
+ assert trimmed.typed_path == ["admin"]
+ assert trimmed.segments[0].text == "admin"
+ assert root.without_last_path_segment() is root
+
+
+def test_invocation_context_plain_path_omits_program_in_menu_mode() -> None:
+ context = (
+ InvocationContext(program="falyx", mode=FalyxMode.MENU)
+ .with_path_segment("admin")
+ .with_path_segment("deploy")
+ )
+
+ assert context.is_cli_mode is False
+ assert context.plain_path == "admin deploy"
+
+
+def test_invocation_context_plain_path_includes_program_in_cli_mode() -> None:
+ context = (
+ InvocationContext(program="falyx", mode=FalyxMode.COMMAND)
+ .with_path_segment("admin")
+ .with_path_segment("deploy")
+ )
+
+ assert context.is_cli_mode is True
+ assert context.plain_path == "falyx admin deploy"
+
+
+def test_invocation_context_plain_path_handles_cli_context_without_program() -> None:
+ context = InvocationContext(mode=FalyxMode.COMMAND).with_path_segment("deploy")
+
+ assert context.plain_path == "deploy"
+
+
+def test_invocation_context_markup_path_styles_program_and_segments_and_escapes_text() -> (
+ None
+):
+ context = (
+ InvocationContext(
+ program="falyx[dev]",
+ program_style="bold blue",
+ mode=FalyxMode.COMMAND,
+ )
+ .with_path_segment("admin[ops]", style="cyan")
+ .with_path_segment("deploy", style="green")
+ )
+
+ assert context.markup_path == (
+ "[bold blue]falyx\\[dev][/bold blue] "
+ "[cyan]admin\\[ops][/cyan] "
+ "[green]deploy[/green]"
+ )
+
+
+def test_invocation_context_markup_path_handles_unstyled_program_and_segments() -> None:
+ context = (
+ InvocationContext(program="falyx", mode=FalyxMode.COMMAND)
+ .with_path_segment("admin[ops]")
+ .with_path_segment("deploy")
+ )
+
+ assert context.markup_path == "falyx admin\\[ops] deploy"
+
+
+def test_invocation_context_markup_path_omits_program_in_menu_mode() -> None:
+ context = (
+ InvocationContext(
+ program="falyx",
+ program_style="bold blue",
+ mode=FalyxMode.MENU,
+ )
+ .with_path_segment("admin", style="cyan")
+ .with_path_segment("deploy")
+ )
+
+ assert context.markup_path == "[cyan]admin[/cyan] deploy"
diff --git a/tests/test_execution_option.py b/tests/test_execution_option.py
new file mode 100644
index 0000000..3688a60
--- /dev/null
+++ b/tests/test_execution_option.py
@@ -0,0 +1,30 @@
+import pytest
+
+from falyx.execution_option import ExecutionOption
+
+
+def test_execution_option_accepts_valid_string_values():
+ assert ExecutionOption("summary") == ExecutionOption.SUMMARY
+ assert ExecutionOption("retry") == ExecutionOption.RETRY
+ assert ExecutionOption("confirm") == ExecutionOption.CONFIRM
+
+
+def test_execution_option_rejects_invalid_string():
+ with pytest.raises(ValueError, match="Invalid ExecutionOption: 'invalid'"):
+ ExecutionOption("invalid")
+
+
+def test_execution_option_normalizes_case_and_whitespace():
+ assert ExecutionOption(" SUMMARY ") == ExecutionOption.SUMMARY
+ assert ExecutionOption("ReTrY") == ExecutionOption.RETRY
+ assert ExecutionOption("\tconfirm\n") == ExecutionOption.CONFIRM
+
+
+def test_execution_option_rejects_non_string():
+ with pytest.raises(ValueError, match="Invalid ExecutionOption: 123"):
+ ExecutionOption(123)
+
+
+def test_execution_option_error_lists_valid_values():
+ with pytest.raises(ValueError, match="Must be one of: summary, retry, confirm"):
+ ExecutionOption("invalid")
diff --git a/tests/test_execution_registry.py b/tests/test_execution_registry.py
new file mode 100644
index 0000000..e3249c3
--- /dev/null
+++ b/tests/test_execution_registry.py
@@ -0,0 +1,307 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any, Iterator
+
+import pytest
+from rich.console import Console
+from rich.table import Table
+
+from falyx.execution_registry import ExecutionRegistry
+
+
+@dataclass
+class DummyAction:
+ ignore_in_history: bool = False
+
+
+class DummyContext:
+ def __init__(
+ self,
+ name: str,
+ *,
+ result: Any = None,
+ exception: Exception | None = None,
+ traceback: str = "",
+ signature: str | None = None,
+ start_time: float | None = 1_700_000_000.0,
+ end_time: float | None = 1_700_000_001.0,
+ duration: float | None = 1.25,
+ ignore_in_history: bool = False,
+ ) -> None:
+ self.index = -1
+ self.name = name
+ self.result = result
+ self.exception = exception
+ self.traceback = traceback
+ self.signature = signature or f"{name}()"
+ self.start_time = start_time
+ self.end_time = end_time
+ self.duration = duration
+ self.action = DummyAction(ignore_in_history=ignore_in_history)
+ self.success = exception is None
+
+ def to_log_line(self) -> str:
+ return f"log:{self.name}:{self.index}"
+
+
+class CaptureConsole:
+ def __init__(self) -> None:
+ self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
+
+ def print(self, *args: Any, **kwargs: Any) -> None:
+ self.printed.append((args, kwargs))
+
+ def rendered_text(self) -> str:
+ output = Console(record=True, width=160)
+ for args, kwargs in self.printed:
+ output.print(*args, **kwargs)
+ return output.export_text()
+
+
+@pytest.fixture(autouse=True)
+def isolated_registry() -> Iterator[CaptureConsole]:
+ original_console = ExecutionRegistry._console
+ capture = CaptureConsole()
+
+ ExecutionRegistry._console = capture # type: ignore[assignment]
+ ExecutionRegistry._store_by_name.clear()
+ ExecutionRegistry._store_by_index.clear()
+ ExecutionRegistry._store_all.clear()
+ ExecutionRegistry._index = 0
+
+ yield capture
+
+ ExecutionRegistry._store_by_name.clear()
+ ExecutionRegistry._store_by_index.clear()
+ ExecutionRegistry._store_all.clear()
+ ExecutionRegistry._index = 0
+ ExecutionRegistry._console = original_console
+
+
+def record_context(*args: Any, **kwargs: Any) -> DummyContext:
+ context = DummyContext(*args, **kwargs)
+ ExecutionRegistry.record(context) # type: ignore[arg-type]
+ return context
+
+
+def latest_printed_table(console: CaptureConsole) -> Table:
+ assert console.printed
+ table = console.printed[-1][0][0]
+ assert isinstance(table, Table)
+ return table
+
+
+def test_record_assigns_indexes_and_populates_all_lookup_stores() -> None:
+ first = record_context("Build", result="ok")
+ second = record_context("Build", result="again")
+ other = record_context("Deploy", result="done")
+
+ assert first.index == 0
+ assert second.index == 1
+ assert other.index == 2
+ assert ExecutionRegistry.get_all() == [first, second, other]
+ assert ExecutionRegistry.get_by_name("Build") == [first, second]
+ assert ExecutionRegistry.get_by_name("missing") == []
+ assert ExecutionRegistry._store_by_index == {0: first, 1: second, 2: other}
+ assert ExecutionRegistry.get_latest() is other
+
+
+def test_clear_removes_all_recorded_contexts() -> None:
+ record_context("Build", result="ok")
+
+ ExecutionRegistry.clear()
+
+ assert ExecutionRegistry.get_all() == []
+ assert ExecutionRegistry.get_by_name("Build") == []
+ assert ExecutionRegistry._store_by_index == {}
+
+
+def test_summary_clear_clears_registry_and_prints_confirmation(
+ isolated_registry: CaptureConsole,
+) -> None:
+ record_context("Build", result="ok")
+
+ ExecutionRegistry.summary(clear=True)
+
+ assert ExecutionRegistry.get_all() == []
+ assert "Execution history cleared" in isolated_registry.rendered_text()
+
+
+def test_summary_last_result_skips_ignored_contexts(
+ isolated_registry: CaptureConsole,
+) -> None:
+ visible = record_context("Visible", result={"answer": 42})
+ record_context("Ignored", result="do not show", ignore_in_history=True)
+
+ ExecutionRegistry.summary(last_result=True)
+
+ assert isolated_registry.printed[0][0] == (f"{visible.signature}:",)
+ assert isolated_registry.printed[1][0] == (visible.result,)
+
+
+def test_summary_last_result_prints_traceback_when_latest_visible_context_failed(
+ isolated_registry: CaptureConsole,
+) -> None:
+ failed = record_context("Fail", exception=RuntimeError("boom"), traceback="TRACEBACK")
+
+ ExecutionRegistry.summary(last_result=True)
+
+ assert isolated_registry.printed[0][0] == (f"{failed.signature}:",)
+ assert isolated_registry.printed[1][0] == ("TRACEBACK",)
+
+
+def test_summary_last_result_reports_when_all_contexts_are_ignored(
+ isolated_registry: CaptureConsole,
+) -> None:
+ record_context("Ignored", result="hidden", ignore_in_history=True)
+
+ ExecutionRegistry.summary(last_result=True)
+
+ assert "No valid executions found" in isolated_registry.rendered_text()
+
+
+def test_summary_result_index_prints_result_for_existing_context(
+ isolated_registry: CaptureConsole,
+) -> None:
+ context = record_context("Build", result=["artifact.whl"])
+
+ ExecutionRegistry.summary(result_index=context.index)
+
+ assert isolated_registry.printed[0][0] == (f"{context.signature}:",)
+ assert isolated_registry.printed[1][0] == (context.result,)
+
+
+def test_summary_result_index_prints_traceback_for_failed_context(
+ isolated_registry: CaptureConsole,
+) -> None:
+ context = record_context("Fail", exception=ValueError("bad"), traceback="STACK")
+
+ ExecutionRegistry.summary(result_index=context.index)
+
+ assert isolated_registry.printed[0][0] == (f"{context.signature}:",)
+ assert isolated_registry.printed[1][0] == ("STACK",)
+
+
+def test_summary_result_index_reports_missing_index(
+ isolated_registry: CaptureConsole,
+) -> None:
+ ExecutionRegistry.summary(result_index=99)
+
+ assert "No execution found for index 99" in isolated_registry.rendered_text()
+
+
+def test_summary_name_filter_reports_missing_action(
+ isolated_registry: CaptureConsole,
+) -> None:
+ record_context("Build", result="ok")
+
+ ExecutionRegistry.summary(name="Deploy")
+
+ assert "No executions found for action 'Deploy'" in isolated_registry.rendered_text()
+
+
+def test_summary_name_filter_renders_only_matching_contexts(
+ isolated_registry: CaptureConsole,
+) -> None:
+ record_context("Build", result="ok")
+ record_context("Deploy", result="done")
+ record_context("Build", result="again")
+
+ ExecutionRegistry.summary(name="Build")
+
+ table = latest_printed_table(isolated_registry)
+ assert table.title == "📊 Execution History for 'Build'"
+ assert len(table.rows) == 2
+ rendered = isolated_registry.rendered_text()
+ assert "Build" in rendered
+ assert "Deploy" not in rendered
+
+
+def test_summary_index_filter_renders_existing_context(
+ isolated_registry: CaptureConsole,
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ first = record_context("Build", result="ok")
+ second = record_context("Deploy", result="done")
+
+ ExecutionRegistry.summary(index=second.index)
+
+ table = latest_printed_table(isolated_registry)
+ assert table.title == f"📊 Execution History for Index {second.index}"
+ assert len(table.rows) == 1
+ rendered = isolated_registry.rendered_text()
+ assert "Deploy" in rendered
+ assert "Build" not in rendered
+ # The implementation currently prints the filtered context list directly.
+ assert str([second]) in capsys.readouterr().out
+ assert first.index == 0
+
+
+def test_summary_index_filter_reports_missing_index(
+ isolated_registry: CaptureConsole,
+) -> None:
+ ExecutionRegistry.summary(index=12)
+
+ assert "No execution found for index 12" in isolated_registry.rendered_text()
+
+
+def test_summary_status_success_filters_out_errors_and_truncates_long_results(
+ isolated_registry: CaptureConsole,
+) -> None:
+ long_result = "x" * 80
+ record_context("Success", result=long_result)
+ record_context("Failure", exception=RuntimeError("boom"))
+
+ ExecutionRegistry.summary(status="success")
+
+ table = latest_printed_table(isolated_registry)
+ assert len(table.rows) == 1
+ rendered = isolated_registry.rendered_text()
+ assert "Success" in rendered
+ assert "Failure" not in rendered
+ assert "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..." in rendered
+
+
+def test_summary_status_error_filters_out_successes(
+ isolated_registry: CaptureConsole,
+) -> None:
+ record_context("Success", result="ok")
+ record_context("Failure", exception=RuntimeError("boom"))
+
+ ExecutionRegistry.summary(status="error")
+
+ table = latest_printed_table(isolated_registry)
+ assert len(table.rows) == 1
+ rendered = isolated_registry.rendered_text()
+ assert "Failure" in rendered
+ assert "RuntimeError" in rendered
+ assert "Success" not in rendered
+
+
+def test_summary_uses_na_for_missing_timestamps_and_duration(
+ isolated_registry: CaptureConsole,
+) -> None:
+ record_context("Pending", result=None, start_time=None, end_time=None, duration=None)
+
+ ExecutionRegistry.summary()
+
+ rendered = isolated_registry.rendered_text()
+ assert "Pending" in rendered
+ assert "n/a" in rendered
+
+
+def test_summary_defaults_to_all_contexts(
+ isolated_registry: CaptureConsole,
+) -> None:
+ record_context("One", result="ok")
+ record_context("Two", exception=RuntimeError("boom"))
+
+ ExecutionRegistry.summary()
+
+ table = latest_printed_table(isolated_registry)
+ assert table.title == "📊 Execution History"
+ assert len(table.rows) == 2
+ rendered = isolated_registry.rendered_text()
+ assert "One" in rendered
+ assert "Two" in rendered
diff --git a/tests/test_falyx/test_builtin_root_options.py b/tests/test_falyx/test_builtin_root_options.py
new file mode 100644
index 0000000..c330f42
--- /dev/null
+++ b/tests/test_falyx/test_builtin_root_options.py
@@ -0,0 +1,55 @@
+import logging
+
+from falyx import Falyx
+from falyx.action import Action
+from falyx.debug import log_after, log_before, log_error, log_success
+from falyx.hook_manager import HookType
+
+
+def test_apply_root_options_sets_falyx_logger_level_from_root_verbose():
+ flx = Falyx()
+
+ falyx_logger = logging.getLogger("falyx")
+ original_level = falyx_logger.level
+ try:
+ flx.options_manager.set("verbose", True, "root")
+ flx._apply_root_options()
+ assert falyx_logger.level == logging.DEBUG
+
+ flx.options_manager.set("verbose", False, "root")
+ flx._apply_root_options()
+ assert falyx_logger.level == logging.WARNING
+ finally:
+ falyx_logger.setLevel(original_level)
+
+
+def test_apply_root_options_registers_debug_hooks_across_command_and_action_graph():
+ action = Action("deploy-action", lambda: "ok")
+ flx = Falyx()
+ command = flx.add_command(
+ key="D",
+ description="Deploy",
+ action=action,
+ )
+
+ assert flx.hooks._hooks[HookType.BEFORE] == []
+ assert command.hooks._hooks[HookType.BEFORE] == []
+ assert action.hooks._hooks[HookType.BEFORE] == []
+
+ flx.options_manager.set("debug_hooks", True, "root")
+ flx._apply_root_options()
+
+ assert flx.hooks._hooks[HookType.BEFORE] == [log_before]
+ assert flx.hooks._hooks[HookType.ON_SUCCESS] == [log_success]
+ assert flx.hooks._hooks[HookType.ON_ERROR] == [log_error]
+ assert flx.hooks._hooks[HookType.AFTER] == [log_after]
+
+ assert command.hooks._hooks[HookType.BEFORE] == [log_before]
+ assert command.hooks._hooks[HookType.ON_SUCCESS] == [log_success]
+ assert command.hooks._hooks[HookType.ON_ERROR] == [log_error]
+ assert command.hooks._hooks[HookType.AFTER] == [log_after]
+
+ assert action.hooks._hooks[HookType.BEFORE] == [log_before]
+ assert action.hooks._hooks[HookType.ON_SUCCESS] == [log_success]
+ assert action.hooks._hooks[HookType.ON_ERROR] == [log_error]
+ assert action.hooks._hooks[HookType.AFTER] == [log_after]
diff --git a/tests/test_falyx/test_command_clone_contract.py b/tests/test_falyx/test_command_clone_contract.py
new file mode 100644
index 0000000..2414bda
--- /dev/null
+++ b/tests/test_falyx/test_command_clone_contract.py
@@ -0,0 +1,138 @@
+import pytest
+
+from falyx import Falyx
+from falyx.action import Action, ChainedAction
+from falyx.command import Command
+from falyx.options_manager import OptionsManager
+from falyx.parser import CommandArgumentParser
+
+
+def test_add_command_from_command_returns_bound_clone():
+ source = Falyx(program="source")
+ target = Falyx(program="target")
+
+ original = source.add_command(
+ "D",
+ "Deploy",
+ action=lambda: "ok",
+ aliases=["deploy"],
+ help_text="Deploy something.",
+ )
+
+ bound = target.add_command_from_command(original)
+
+ assert bound is target.commands["D"]
+ assert bound is not original
+ assert bound.key == original.key
+ assert bound.description == original.description
+ assert bound.aliases == original.aliases
+ assert bound.program == target.program
+
+
+def test_add_command_from_command_does_not_reuse_original_options_manager():
+ source = Falyx(program="source")
+ target = Falyx(program="target")
+
+ original = source.add_command("D", "Deploy", action=lambda: "ok")
+ bound = target.add_command_from_command(original)
+
+ assert original.options_manager is source.options_manager
+ assert bound.options_manager is target.options_manager
+ assert bound.options_manager is not original.options_manager
+
+
+def test_add_command_from_command_returns_isolated_clone():
+ flx1 = Falyx(program="one")
+ flx2 = Falyx(program="two")
+
+ original = flx1.add_command("D", "Deploy", action=Action("deploy", lambda: "ok"))
+ bound = flx2.add_command_from_command(original)
+
+ assert bound is not original
+ assert bound.options_manager is flx2.options_manager
+ assert original.options_manager is flx1.options_manager
+
+ if bound.arg_parser and original.arg_parser:
+ assert bound.arg_parser is not original.arg_parser
+ assert bound.arg_parser.options_manager is flx2.options_manager
+ assert original.arg_parser.options_manager is flx1.options_manager
+
+ assert bound.action is not original.action
+
+
+def test_clone_with_overrides_clones_arg_parser_and_base_action_graph():
+ original_options = OptionsManager()
+ cloned_options = OptionsManager()
+
+ parser = CommandArgumentParser(
+ command_key="D",
+ command_description="Deploy",
+ options_manager=original_options,
+ )
+ parser.add_argument("--region", default="us-east")
+
+ action = ChainedAction(
+ name="deploy-flow",
+ actions=[
+ Action("step-one", lambda: "one"),
+ Action("step-two", lambda: "two"),
+ ],
+ )
+
+ command = Command.build(
+ key="D",
+ description="Deploy",
+ action=action,
+ arg_parser=parser,
+ options_manager=original_options,
+ program="source",
+ )
+
+ cloned = command.clone_with_overrides(
+ options_manager=cloned_options,
+ program="target",
+ )
+
+ assert cloned is not command
+ assert cloned.program == "target"
+ assert cloned.options_manager is cloned_options
+ assert command.options_manager is original_options
+
+ assert cloned.arg_parser is not command.arg_parser
+ assert cloned.arg_parser.options_manager is cloned_options
+ assert command.arg_parser.options_manager is original_options
+
+ assert cloned.action is not command.action
+ assert isinstance(cloned.action, ChainedAction)
+ assert isinstance(command.action, ChainedAction)
+
+ assert cloned.action.actions is not command.action.actions
+ assert len(cloned.action.actions) == len(command.action.actions)
+
+ for cloned_child, original_child in zip(
+ cloned.action.actions,
+ command.action.actions,
+ strict=True,
+ ):
+ assert cloned_child is not original_child
+ assert cloned_child.name == original_child.name
+
+ cloned.arg_parser.add_argument("--profile", default="dev")
+ assert command.arg_parser.get_argument("profile") is None
+
+
+def test_clone_with_overrides_preserves_boolean_contract_flags():
+ command = Command.build(
+ "H",
+ "Hidden-ish helper",
+ lambda: None,
+ auto_args=False,
+ simple_help_signature=True,
+ ignore_in_history=True,
+ )
+
+ cloned = command.clone_with_overrides()
+
+ assert cloned.auto_args is False
+ assert cloned.simple_help_signature is True
+ assert cloned.ignore_in_history is True
diff --git a/tests/test_falyx/test_command_prompt_contract.py b/tests/test_falyx/test_command_prompt_contract.py
new file mode 100644
index 0000000..699b964
--- /dev/null
+++ b/tests/test_falyx/test_command_prompt_contract.py
@@ -0,0 +1,197 @@
+from unittest.mock import AsyncMock
+
+import pytest
+
+from falyx.action import Action
+from falyx.command import Command
+from falyx.options_manager import OptionsManager
+from falyx.prompt_utils import should_prompt_user
+from falyx.signals import CancelSignal
+
+
+def _make_options() -> OptionsManager:
+ options = OptionsManager()
+ options.from_mapping({}, "root")
+ options.from_mapping({}, "execution")
+ return options
+
+
+@pytest.mark.asyncio
+async def test_command_handle_prompt_respects_action_local_never_prompt(monkeypatch):
+ options = _make_options()
+
+ command = Command.build(
+ key="D",
+ description="Deploy",
+ action=Action("deploy-action", lambda: "ok", never_prompt=True),
+ confirm=True,
+ preview_before_confirm=True,
+ options_manager=options,
+ )
+
+ calls = {
+ "preview": 0,
+ "confirm": 0,
+ "should_prompt": 0,
+ "action_never_prompt": None,
+ }
+
+ async def fake_preview(self):
+ calls["preview"] += 1
+
+ async def fake_confirm(*args, **kwargs):
+ calls["confirm"] += 1
+ return True
+
+ def fake_should_prompt_user(*, confirm, options, action_never_prompt=None, **kwargs):
+ calls["should_prompt"] += 1
+ calls["action_never_prompt"] = action_never_prompt
+ return False
+
+ monkeypatch.setattr(Command, "preview", fake_preview)
+ monkeypatch.setattr("falyx.command.confirm_async", fake_confirm)
+ monkeypatch.setattr("falyx.command.should_prompt_user", fake_should_prompt_user)
+
+ await command._handle_prompt_user()
+
+ assert calls["should_prompt"] == 1
+ assert calls["action_never_prompt"] is True
+ assert calls["preview"] == 0
+ assert calls["confirm"] == 0
+
+
+def test_should_prompt_user_precedence_execution_over_root():
+ options = _make_options()
+ options.set("force_confirm", True, "root")
+ options.set("skip_confirm", True, "execution")
+
+ assert should_prompt_user(confirm=False, options=options) is False
+
+ options = _make_options()
+ options.set("never_prompt", False, "root")
+ options.set("force_confirm", True, "execution")
+
+ assert should_prompt_user(confirm=False, options=options) is True
+
+
+@pytest.mark.asyncio
+async def test_command_local_never_prompt_overrides_root_prompt_behavior(monkeypatch):
+ options = _make_options()
+ options.set("force_confirm", True, "root")
+
+ command = Command.build(
+ key="D",
+ description="Deploy",
+ action=Action("deploy-action", lambda: "ok", never_prompt=True),
+ confirm=False,
+ preview_before_confirm=True,
+ options_manager=options,
+ )
+
+ calls = {
+ "preview": 0,
+ "confirm": 0,
+ }
+
+ async def fake_preview(self):
+ calls["preview"] += 1
+
+ async def fake_confirm(*args, **kwargs):
+ calls["confirm"] += 1
+ return True
+
+ monkeypatch.setattr(Command, "preview", fake_preview)
+ monkeypatch.setattr("falyx.command.confirm_async", fake_confirm)
+
+ await command._handle_prompt_user()
+
+ assert calls["preview"] == 0
+ assert calls["confirm"] == 0
+
+
+@pytest.mark.asyncio
+async def test_command_call_invokes_handle_prompt_user(monkeypatch):
+ options = OptionsManager()
+ options.from_mapping({}, "root")
+ options.from_mapping({}, "execution")
+
+ command = Command.build(
+ key="D",
+ description="Deploy",
+ action=Action("deploy-action", lambda: "ok"),
+ confirm=True,
+ options_manager=options,
+ )
+
+ mocked_handle_prompt = AsyncMock()
+ monkeypatch.setattr(command, "_handle_prompt_user", mocked_handle_prompt)
+
+ result = await command()
+
+ mocked_handle_prompt.assert_awaited_once()
+ assert result == "ok"
+
+
+@pytest.mark.asyncio
+async def test_command_call_invokes_handle_prompt_user_before_action(monkeypatch) -> None:
+ trace: list[str] = []
+
+ async def run_action():
+ trace.append("action")
+ return "ok"
+
+ options = OptionsManager()
+ options.from_mapping({}, "root")
+ options.from_mapping({}, "execution")
+
+ command = Command.build(
+ key="D",
+ description="Deploy",
+ action=Action("deploy-action", run_action),
+ confirm=True,
+ options_manager=options,
+ )
+
+ async def fake_handle_prompt_user():
+ trace.append("prompt")
+
+ monkeypatch.setattr(command, "_handle_prompt_user", fake_handle_prompt_user)
+
+ result = await command()
+
+ assert result == "ok"
+ assert trace == ["prompt", "action"]
+
+
+@pytest.mark.asyncio
+async def test_command_call_cancels_before_action_when_handle_prompt_user_raises(
+ monkeypatch,
+):
+ trace: list[str] = []
+
+ async def run_action():
+ trace.append("action")
+ return "ok"
+
+ options = OptionsManager()
+ options.from_mapping({}, "root")
+ options.from_mapping({}, "execution")
+
+ command = Command.build(
+ key="D",
+ description="Deploy",
+ action=Action("deploy-action", run_action),
+ confirm=True,
+ options_manager=options,
+ )
+
+ async def fake_handle_prompt_user():
+ trace.append("prompt")
+ raise CancelSignal("cancelled during confirmation")
+
+ monkeypatch.setattr(command, "_handle_prompt_user", fake_handle_prompt_user)
+
+ with pytest.raises(CancelSignal, match="cancelled during confirmation"):
+ await command()
+
+ assert trace == ["prompt"]
diff --git a/tests/test_falyx/test_completion_contract.py b/tests/test_falyx/test_completion_contract.py
new file mode 100644
index 0000000..b35a0a5
--- /dev/null
+++ b/tests/test_falyx/test_completion_contract.py
@@ -0,0 +1,121 @@
+from prompt_toolkit.document import Document
+
+from falyx import Falyx
+from falyx.completer import FalyxCompleter
+from falyx.parser import CommandArgumentParser
+
+
+def completion_texts(completions) -> list[str]:
+ return [c.text for c in completions]
+
+
+def make_completion_app() -> tuple[Falyx, FalyxCompleter]:
+ flx = Falyx(program="falyx")
+
+ flx.add_option(
+ "--profile",
+ suggestions=["dev", "prod", "staging"],
+ help="Runtime profile",
+ )
+
+ flx.add_option(
+ "--region",
+ choices=["us-east", "us-west"],
+ help="Deployment region",
+ )
+
+ parser = CommandArgumentParser()
+ parser.add_argument("--name")
+ parser.add_argument("--env", choices=["dev", "prod"])
+
+ flx.add_command(
+ key="D",
+ description="Deploy",
+ action=lambda name, env: f"deploy {name} to {env}",
+ aliases=["deploy"],
+ arg_parser=parser,
+ )
+
+ return flx, FalyxCompleter(flx)
+
+
+def test_completion_suggests_namespace_flags():
+ _, completer = make_completion_app()
+
+ completions = list(
+ completer.get_completions(Document(text="--pr", cursor_position=4), None)
+ )
+
+ texts = completion_texts(completions)
+ assert "--profile" in texts
+
+
+def test_completion_suggests_namespace_option_values():
+ _, completer = make_completion_app()
+
+ completions = list(
+ completer.get_completions(
+ Document(text="--profile pr", cursor_position=len("--profile pr")),
+ None,
+ )
+ )
+
+ texts = completion_texts(completions)
+ assert "prod" in texts
+ assert "dev" not in texts
+
+
+def test_completion_after_committed_namespace_option_returns_namespace_entries():
+ _, completer = make_completion_app()
+
+ completions = list(
+ completer.get_completions(
+ Document(text="--profile prod de", cursor_position=len("--profile prod de")),
+ None,
+ )
+ )
+
+ texts = completion_texts(completions)
+ assert "deploy" in texts
+
+
+def test_completion_preview_mode_prefixes_namespace_entry_suggestions():
+ _, completer = make_completion_app()
+
+ completions = list(
+ completer.get_completions(Document(text="?de", cursor_position=3), None)
+ )
+
+ texts = completion_texts(completions)
+ assert "?deploy" in texts
+
+
+def test_resolve_completion_route_unresolved_entry_with_trailing_input_stops_namespace_entry_mode():
+ flx, _ = make_completion_app()
+
+ route = flx.resolve_completion_route(
+ ["wat"],
+ stub="--na",
+ cursor_at_end_of_token=False,
+ invocation_context=flx.get_current_invocation_context(),
+ is_preview=False,
+ )
+
+ assert route.command is None
+ assert route.expecting_entry is False
+ assert route.remaining_argv == ["wat", "--na"]
+ assert route.stub == ""
+
+
+def test_completion_delegates_to_command_parser_after_leaf_command_is_resolved():
+ _, completer = make_completion_app()
+
+ completions = list(
+ completer.get_completions(
+ Document(text="D --na", cursor_position=len("D --na")),
+ None,
+ )
+ )
+
+ texts = completion_texts(completions)
+ assert "--name" in texts
diff --git a/tests/test_falyx/test_dispatch_contract.py b/tests/test_falyx/test_dispatch_contract.py
new file mode 100644
index 0000000..ae524f5
--- /dev/null
+++ b/tests/test_falyx/test_dispatch_contract.py
@@ -0,0 +1,120 @@
+import pytest
+
+from falyx import Falyx
+from falyx.routing import RouteKind, RouteResult
+
+
+@pytest.mark.asyncio
+async def test_dispatch_seeds_namespace_defaults_into_default_namespace(
+ monkeypatch,
+):
+ flx = Falyx(program="falyx")
+ command = flx.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"])
+
+ route = RouteResult(
+ kind=RouteKind.COMMAND,
+ namespace=flx,
+ context=flx.get_current_invocation_context(),
+ command=command,
+ namespace_defaults={"region": "us-east"},
+ namespace_overrides={},
+ )
+
+ seen = {}
+
+ async def fake_execute(*, command, args, kwargs, execution_args, **_):
+ seen["region"] = flx.options_manager.get("region", None, "default")
+ return "ok"
+
+ monkeypatch.setattr(flx._executor, "execute", fake_execute)
+
+ result = await flx._dispatch_route(
+ route=route,
+ args=(),
+ kwargs={},
+ execution_args={},
+ )
+
+ assert result == "ok"
+ assert seen["region"] == "us-east"
+
+ assert flx.options_manager.get("region", None, "default") == "us-east"
+
+
+@pytest.mark.asyncio
+async def test_dispatch_applies_namespace_overrides_temporarily_in_default_namespace(
+ monkeypatch,
+):
+ flx = Falyx(program="falyx")
+ command = flx.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"])
+
+ flx.options_manager.set("region", "us-east", "default")
+
+ route = RouteResult(
+ kind=RouteKind.COMMAND,
+ namespace=flx,
+ context=flx.get_current_invocation_context(),
+ command=command,
+ namespace_defaults={},
+ namespace_overrides={"region": "us-west"},
+ )
+
+ seen = {}
+
+ async def fake_execute(*, command, args, kwargs, execution_args, **_):
+ seen["region"] = flx.options_manager.get("region", None, "default")
+ return "ok"
+
+ monkeypatch.setattr(flx._executor, "execute", fake_execute)
+
+ result = await flx._dispatch_route(
+ route=route,
+ args=(),
+ kwargs={},
+ execution_args={},
+ raise_on_error=False,
+ wrap_errors=True,
+ )
+
+ assert result == "ok"
+ assert seen["region"] == "us-west"
+
+ assert flx.options_manager.get("region", None, "default") == "us-east"
+
+
+@pytest.mark.asyncio
+async def test_namespace_overrides_do_not_leak_after_command_execution(monkeypatch):
+ flx = Falyx(program="falyx")
+ command = flx.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"])
+
+ flx.options_manager.set("profile", "dev", "default")
+
+ route = RouteResult(
+ kind=RouteKind.COMMAND,
+ namespace=flx,
+ context=flx.get_current_invocation_context(),
+ command=command,
+ namespace_defaults={"region": "us-east"},
+ namespace_overrides={"profile": "prod"},
+ )
+
+ async def fake_execute(*, command, args, kwargs, execution_args, **_):
+ assert flx.options_manager.get("region", None, "default") == "us-east"
+ assert flx.options_manager.get("profile", None, "default") == "prod"
+ return "ok"
+
+ monkeypatch.setattr(flx._executor, "execute", fake_execute)
+
+ result = await flx._dispatch_route(
+ route=route,
+ args=(),
+ kwargs={},
+ execution_args={},
+ raise_on_error=False,
+ wrap_errors=True,
+ )
+
+ assert result == "ok"
+
+ assert flx.options_manager.get("region", None, "default") == "us-east"
+ assert flx.options_manager.get("profile", None, "default") == "dev"
diff --git a/tests/test_falyx/test_exceptions.py b/tests/test_falyx/test_exceptions.py
new file mode 100644
index 0000000..e15226f
--- /dev/null
+++ b/tests/test_falyx/test_exceptions.py
@@ -0,0 +1,68 @@
+import pytest
+
+from falyx.console import print_error
+from falyx.exceptions import CommandArgumentError, MissingValueError
+from falyx.parser import CommandArgumentParser
+
+
+async def test_missing_value_error_has_user_facing_message():
+ parser = CommandArgumentParser()
+ parser.add_argument("--pair", type=int, nargs=2)
+
+ with pytest.raises(MissingValueError) as exc:
+ await parser.parse_args(["--pair", "1"])
+
+ assert "pair" in str(exc.value)
+ assert "expected" in str(exc.value).lower()
+
+
+@pytest.mark.asyncio
+async def test_missing_value_error_for_fixed_nargs_has_message_and_hint():
+ parser = CommandArgumentParser()
+ parser.add_argument("--pair", type=int, nargs=2)
+
+ with pytest.raises(MissingValueError) as exc:
+ await parser.parse_args(["--pair", "1"])
+
+ error = exc.value
+
+ assert str(error) == "missing values for '--pair': expected 2, got 1"
+ assert error.hint == "provide 2 values for '--pair'."
+ assert error.show_short_usage is True
+ assert error.dest == "pair"
+
+
+@pytest.mark.asyncio
+async def test_missing_value_error_for_plus_nargs_has_message_and_hint():
+ parser = CommandArgumentParser()
+ parser.add_argument("--items", nargs="+")
+
+ with pytest.raises(MissingValueError) as exc:
+ await parser.parse_args(["--items"])
+
+ error = exc.value
+
+ assert str(error) == "missing value for '--items'"
+ assert error.hint == "provide one or more values for '--items'."
+
+
+def test_print_error_uses_exception_hint(monkeypatch) -> None:
+ printed: list[str] = []
+
+ class FakeConsole:
+ def print(self, value):
+ printed.append(value)
+
+ monkeypatch.setattr("falyx.console.error_console", FakeConsole())
+
+ error = CommandArgumentError(
+ "invalid command argument",
+ hint="use --help to see available options",
+ )
+
+ print_error(error)
+
+ assert any("error:" in line for line in printed)
+ assert any("invalid command argument" in line for line in printed)
+ assert any("hint:" in line for line in printed)
+ assert any("use --help to see available options" in line for line in printed)
diff --git a/tests/test_falyx/test_execute_command.py b/tests/test_falyx/test_execute_command.py
new file mode 100644
index 0000000..5e42dde
--- /dev/null
+++ b/tests/test_falyx/test_execute_command.py
@@ -0,0 +1,124 @@
+import pytest
+
+from falyx import Falyx
+from falyx.action import Action
+from falyx.command_runner import CommandRunner
+from falyx.parser import CommandArgumentParser
+
+
+@pytest.mark.asyncio
+async def test_execute_command():
+ """Test if Falyx can run in run key mode."""
+ falyx = Falyx("Run Key Test")
+
+ falyx.add_command(
+ key="T",
+ description="Test Command",
+ action=lambda: "Hello, World!",
+ )
+
+ result = await falyx.execute_command("T")
+ assert result == "Hello, World!"
+
+
+@pytest.mark.asyncio
+async def test_execute_command_accepts_alias():
+ """Falyx.execute_command should resolve command aliases."""
+ falyx = Falyx("Alias Test")
+
+ falyx.add_command(
+ key="T",
+ description="Test Command",
+ action=lambda: "Hello, Alias!",
+ aliases=["test"],
+ )
+
+ result = await falyx.execute_command("test")
+ assert result == "Hello, Alias!"
+
+
+@pytest.mark.asyncio
+async def test_execute_command_recover():
+ """Test if Falyx can recover from a failure in run key mode."""
+ falyx = Falyx("Run Key Recovery Test")
+
+ state = {"count": 0}
+
+ async def flaky():
+ if not state["count"]:
+ state["count"] += 1
+ raise RuntimeError("Random failure!")
+ return "ok"
+
+ falyx.add_command(
+ key="E",
+ description="Error Command",
+ action=Action("flaky", flaky),
+ retry=True,
+ )
+
+ result = await falyx.execute_command("E")
+ assert result == "ok"
+
+
+@pytest.mark.asyncio
+async def test_execute_command_with_argument_parsing():
+ """Falyx.execute_command should parse command-local arguments before execution."""
+ falyx = Falyx("Argument Parsing Test")
+
+ falyx.add_command(
+ key="G",
+ description="Greet",
+ action=lambda name: f"hello {name}",
+ )
+
+ result = await falyx.execute_command("G Roland")
+ assert result == "hello Roland"
+
+
+@pytest.mark.asyncio
+async def test_command_runner_and_falyx_execute_same_command_with_same_result():
+ """CommandRunner and Falyx should produce the same result for equivalent input."""
+ falyx = Falyx("Parity Test")
+
+ command = falyx.add_command(
+ key="G",
+ description="Greet",
+ action=lambda name: f"hello {name}",
+ aliases=["greet"],
+ )
+
+ runner = CommandRunner.from_command(command)
+
+ falyx_result = await falyx.execute_command("G Roland")
+ runner_result = await runner.run(["Roland"])
+
+ assert falyx_result == "hello Roland"
+ assert runner_result == "hello Roland"
+ assert falyx_result == runner_result
+
+
+@pytest.mark.asyncio
+async def test_command_runner_from_command_clones_and_preserves_parity():
+ """Runner parity should hold even though from_command binds a clone."""
+ falyx = Falyx("Clone Parity Test")
+
+ parser = CommandArgumentParser()
+ parser.add_argument("x", type=int)
+ parser.add_argument("y", type=int)
+
+ command = falyx.add_command(
+ key="A",
+ description="Add",
+ action=lambda x, y: x + y,
+ arg_parser=parser,
+ )
+
+ runner = CommandRunner.from_command(command)
+
+ result_from_falyx = await falyx.execute_command("A 2 3")
+ result_from_runner = await runner.run(["2", "3"])
+
+ assert result_from_falyx == 5
+ assert result_from_runner == 5
+ assert runner.command is not command
diff --git a/tests/test_falyx/test_extra.py b/tests/test_falyx/test_extra.py
new file mode 100644
index 0000000..949b786
--- /dev/null
+++ b/tests/test_falyx/test_extra.py
@@ -0,0 +1,856 @@
+from __future__ import annotations
+
+import asyncio
+from contextlib import nullcontext
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+from prompt_toolkit.validation import ValidationError
+from rich.table import Table
+from rich.text import Text
+
+import falyx.falyx as falyx_module
+from falyx import Falyx
+from falyx.command import Command
+from falyx.exceptions import (
+ CommandAlreadyExistsError,
+ CommandArgumentError,
+ EntryNotFoundError,
+ FalyxError,
+ InvalidActionError,
+ InvalidHookError,
+ NotAFalyxError,
+ UsageError,
+)
+from falyx.hook_manager import HookType
+from falyx.mode import FalyxMode
+from falyx.namespace import FalyxNamespace
+from falyx.parser.parser_types import FalyxTLDRExample
+from falyx.routing import RouteKind, RouteResult
+from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
+
+
+class RecordingConsole:
+ def __init__(self) -> None:
+ self.calls: list[tuple[tuple, dict]] = []
+
+ def print(self, *args, **kwargs) -> None:
+ self.calls.append((args, kwargs))
+
+ @property
+ def rendered(self) -> str:
+ parts: list[str] = []
+ for args, _ in self.calls:
+ if args:
+ value = args[0]
+ if isinstance(value, Text):
+ parts.append(value.plain)
+ else:
+ parts.append(str(value))
+ return "\n".join(parts)
+
+
+def make_falyx(**overrides) -> Falyx:
+ defaults = {
+ "program": "fx",
+ "description": "Test CLI",
+ "enable_help_tips": False,
+ }
+ defaults.update(overrides)
+ return Falyx(**defaults)
+
+
+def add_deploy(
+ flx: Falyx, *, key: str = "D", aliases: list[str] | None = None
+) -> Command:
+ return flx.add_command(
+ key,
+ description="Deploy",
+ action=lambda: "deployed",
+ aliases=aliases if aliases is not None else ["deploy"],
+ help_text="Deploy things.",
+ )
+
+
+def route_for(flx: Falyx, kind: RouteKind, **overrides) -> RouteResult:
+ values = {
+ "kind": kind,
+ "namespace": flx,
+ "context": flx.get_current_invocation_context(),
+ }
+ values.update(overrides)
+ return RouteResult(**values)
+
+
+def test_init_with_prompt_history_sanitizes_program_name(tmp_path) -> None:
+ flx = Falyx(
+ program="my app.cli",
+ prompt_history_base_dir=tmp_path,
+ enable_prompt_history=True,
+ )
+
+ assert flx.history_path == tmp_path / ".my_app_history"
+ assert flx.history is not None
+
+
+def test_str_and_repr_include_identity_fields() -> None:
+ flx = Falyx(program="fx", title="Deployments", description="Deploy CLI")
+
+ expected = "Falyx(program='fx', title='Deployments', description='Deploy CLI')"
+ assert str(flx) == expected
+ assert repr(flx) == expected
+
+
+def test_add_tldr_examples_delegates_to_root_parser() -> None:
+ flx = make_falyx()
+ add_deploy(flx)
+
+ flx.add_tldr_examples([("deploy", "--region us-east", "Deploy east")])
+
+ assert flx.parser.tldr_option is not None
+ assert flx.parser._tldr_examples[-1].entry_key == "deploy"
+
+
+def test_rejects_invalid_options_manager() -> None:
+ with pytest.raises(NotAFalyxError, match="options_manager"):
+ Falyx(options_manager=object())
+
+
+def test_entry_map_rejects_identifier_collision_with_distinct_entries() -> None:
+ flx = make_falyx()
+ add_deploy(flx)
+ flx.namespaces["N"] = FalyxNamespace(
+ key="N",
+ description="Nested",
+ namespace=make_falyx(),
+ aliases=["Deploy"],
+ )
+
+ with pytest.raises(CommandAlreadyExistsError, match="identifier 'DEPLOY'"):
+ _ = flx._entry_map
+
+
+def test_get_tip_adds_menu_specific_tips(monkeypatch) -> None:
+ flx = make_falyx()
+ flx.options_manager.set("mode", FalyxMode.MENU)
+ seen: dict[str, list[str]] = {}
+
+ def choose_last(tips: list[str]) -> str:
+ seen["tips"] = tips
+ return tips[-1]
+
+ monkeypatch.setattr(falyx_module, "choice", choose_last)
+
+ assert flx.get_tip() == "Use '[X]' in menu mode to exit."
+ assert "'[Y]' opens the command history viewer." in seen["tips"]
+
+
+def test_command_key_usage_in_menu_includes_history_and_exit() -> None:
+ flx = make_falyx()
+ add_deploy(flx)
+ flx.options_manager.set("mode", FalyxMode.MENU)
+
+ usage = flx._get_command_keys_usage_string()
+
+ assert "D" in usage
+ assert "Y" in usage
+ assert "X" in usage
+
+
+def test_simple_usage_mentions_namespace_when_visible_namespace_exists() -> None:
+ flx = make_falyx(simple_usage=True)
+ flx.add_submenu("OPS", "Operations", make_falyx())
+
+ fragment = flx._get_usage_fragment(flx.get_current_invocation_context())
+
+ assert "" in fragment
+
+
+def test_get_usage_omits_invocation_path_in_menu_mode() -> None:
+ flx = make_falyx(usage="custom [args]")
+ flx.options_manager.set("mode", FalyxMode.MENU)
+
+ usage = flx._get_usage()
+
+ assert usage == "[bold]usage:[/bold] [white]custom [args][/white]"
+
+
+@pytest.mark.asyncio
+async def test_render_command_tldr_prints_tip_when_examples_render(monkeypatch) -> None:
+ flx = make_falyx(enable_help_tips=True)
+ flx.console = RecordingConsole()
+ monkeypatch.setattr(flx, "get_tip", lambda: "remember aliases")
+
+ command = SimpleNamespace(
+ description="Deploy",
+ render_tldr=lambda invocation_context: True,
+ )
+
+ await flx._render_command_tldr(command)
+
+ assert "remember aliases" in flx.console.rendered
+
+
+@pytest.mark.asyncio
+async def test_render_command_tldr_prints_error_when_no_examples(monkeypatch) -> None:
+ flx = make_falyx()
+ messages: list[str] = []
+ monkeypatch.setattr(
+ falyx_module, "print_error", lambda message, **_: messages.append(str(message))
+ )
+ command = SimpleNamespace(
+ description="Deploy",
+ render_tldr=lambda invocation_context: False,
+ )
+
+ await flx._render_command_tldr(command)
+
+ assert messages == ["No TLDR examples available for 'Deploy'."]
+
+
+@pytest.mark.asyncio
+async def test_render_command_help_delegates_to_tldr(monkeypatch) -> None:
+ flx = make_falyx()
+ command = SimpleNamespace(description="Deploy")
+ context = flx.get_current_invocation_context()
+ called: dict[str, object] = {}
+
+ async def fake_tldr(rendered_command, invocation_context=None) -> None:
+ called["command"] = rendered_command
+ called["context"] = invocation_context
+
+ monkeypatch.setattr(flx, "_render_command_tldr", fake_tldr)
+
+ await flx._render_command_help(command, tldr=True, invocation_context=context)
+
+ assert called == {"command": command, "context": context}
+
+
+@pytest.mark.asyncio
+async def test_render_command_help_prints_tip_when_help_renders(monkeypatch) -> None:
+ flx = make_falyx(enable_help_tips=True)
+ flx.console = RecordingConsole()
+ monkeypatch.setattr(flx, "get_tip", lambda: "read the usage line")
+ command = SimpleNamespace(
+ description="Deploy",
+ render_help=lambda invocation_context: True,
+ )
+
+ await flx._render_command_help(command)
+
+ assert "read the usage line" in flx.console.rendered
+
+
+@pytest.mark.asyncio
+async def test_render_command_help_prints_error_when_no_help(monkeypatch) -> None:
+ flx = make_falyx()
+ messages: list[str] = []
+ monkeypatch.setattr(
+ falyx_module, "print_error", lambda message, **_: messages.append(str(message))
+ )
+ command = SimpleNamespace(
+ description="Deploy",
+ render_help=lambda invocation_context: False,
+ )
+
+ await flx._render_command_help(command)
+
+ assert messages == ["No detailed help available for 'Deploy'."]
+
+
+@pytest.mark.asyncio
+async def test_render_tag_help_prints_empty_tag_message() -> None:
+ flx = make_falyx()
+ flx.console = RecordingConsole()
+
+ await flx._render_tag_help("missing")
+
+ assert "Nothing to show here" in flx.console.rendered
+
+
+@pytest.mark.asyncio
+async def test_render_menu_help_includes_namespaces_and_epilog(monkeypatch) -> None:
+ monkeypatch.setattr(
+ FalyxNamespace,
+ "get_help_signature",
+ lambda self, context: (self.key, self.description, ""),
+ raising=False,
+ )
+ flx = make_falyx(epilog="Menu epilog")
+ flx.console = RecordingConsole()
+ flx.add_submenu("OPS", "Operations namespace", make_falyx())
+
+ await flx._render_menu_help(flx.get_current_invocation_context())
+
+ assert "namespaces" in flx.console.rendered
+ assert "Menu epilog" in flx.console.rendered
+
+
+@pytest.mark.asyncio
+async def test_render_cli_help_includes_namespaces_aliases_and_epilog() -> None:
+ flx = make_falyx(epilog="CLI epilog")
+ flx.console = RecordingConsole()
+ flx.add_submenu("OPS", "Operations namespace", make_falyx(), aliases=["operations"])
+
+ await flx._render_cli_help(flx.get_current_invocation_context())
+
+ rendered = flx.console.rendered
+ assert "namespaces" in rendered
+ assert "OPS | operations" in rendered
+ assert "Operations namespace" in rendered
+ assert "CLI epilog" in rendered
+
+
+@pytest.mark.asyncio
+async def test_namespace_tldr_prints_empty_message_without_examples() -> None:
+ flx = make_falyx(title="Root Menu")
+ flx.console = RecordingConsole()
+
+ await flx._render_namespace_tldr_help(flx.get_current_invocation_context())
+
+ assert "No TLDR examples available for 'Root Menu'" in flx.console.rendered
+
+
+@pytest.mark.asyncio
+async def test_namespace_tldr_rejects_stale_unknown_example() -> None:
+ flx = make_falyx()
+ flx.parser.tldr_option = object()
+ flx.parser._tldr_examples.append(
+ FalyxTLDRExample(
+ entry_key="missing",
+ usage="",
+ description="Stale example",
+ )
+ )
+
+ with pytest.raises(EntryNotFoundError) as error:
+ await flx._render_namespace_tldr_help(flx.get_current_invocation_context())
+
+ assert error.value.unknown_name == "missing"
+
+
+def test_help_target_base_context_handles_empty_and_help_command_path() -> None:
+ flx = make_falyx()
+ base_context = flx.get_current_invocation_context()
+
+ assert flx._help_target_base_context(base_context) is base_context
+
+ help_context = base_context.with_path_segment("H", style=flx.help_command.style)
+ stripped = flx._help_target_base_context(help_context)
+
+ assert stripped.typed_path == []
+
+
+@pytest.mark.asyncio
+async def test_render_help_dispatches_to_specific_command(monkeypatch) -> None:
+ flx = make_falyx()
+ command = add_deploy(flx)
+ called: dict[str, object] = {}
+
+ async def fake_command_help(command, tldr=False, invocation_context=None) -> None:
+ called["command"] = command
+ called["tldr"] = tldr
+ called["path"] = list(invocation_context.typed_path)
+
+ monkeypatch.setattr(flx, "_render_command_help", fake_command_help)
+
+ await flx.render_help(key="deploy", tldr=True)
+
+ assert called == {"command": command, "tldr": True, "path": ["deploy"]}
+
+
+@pytest.mark.asyncio
+async def test_render_help_dispatches_to_specific_namespace(monkeypatch) -> None:
+ flx = make_falyx()
+ submenu = make_falyx()
+ called: dict[str, object] = {}
+ flx.add_submenu("OPS", "Operations", submenu)
+
+ async def fake_namespace_help(invocation_context=None, tldr=False) -> None:
+ called["tldr"] = tldr
+ called["path"] = list(invocation_context.typed_path)
+
+ monkeypatch.setattr(submenu, "render_namespace_help", fake_namespace_help)
+
+ await flx.render_help(key="OPS", tldr=True)
+
+ assert called == {"tldr": True, "path": ["OPS"]}
+
+
+@pytest.mark.asyncio
+async def test_render_help_renders_namespace_then_raises_for_unknown_key(
+ monkeypatch,
+) -> None:
+ flx = make_falyx()
+ rendered: list[InvocationContext] = []
+
+ async def fake_namespace_help(invocation_context=None, tldr=False) -> None:
+ rendered.append(invocation_context)
+
+ monkeypatch.setattr(flx, "render_namespace_help", fake_namespace_help)
+
+ with pytest.raises(EntryNotFoundError) as error:
+ await flx.render_help(key="depoy")
+
+ assert rendered
+ assert error.value.unknown_name == "depoy"
+
+
+@pytest.mark.asyncio
+async def test_render_help_without_key_tldr_renders_help_command_tldr(
+ monkeypatch,
+) -> None:
+ flx = make_falyx()
+ called: dict[str, object] = {}
+
+ async def fake_command_help(command, tldr=False, invocation_context=None) -> None:
+ called["command"] = command
+ called["tldr"] = tldr
+
+ monkeypatch.setattr(flx, "_render_command_help", fake_command_help)
+
+ await flx.render_help(tldr=True)
+
+ assert called == {"command": flx.help_command, "tldr": True}
+
+
+@pytest.mark.asyncio
+async def test_preview_rejects_namespaces_and_unknown_entries() -> None:
+ flx = make_falyx()
+ flx.add_submenu("OPS", "Operations", make_falyx())
+
+ with pytest.raises(FalyxError, match="preview mode"):
+ await flx._preview("OPS")
+
+ with pytest.raises(EntryNotFoundError) as error:
+ await flx._preview("missing")
+
+ assert error.value.unknown_name == "missing"
+
+
+@pytest.mark.asyncio
+async def test_render_version_prints_program_version() -> None:
+ flx = make_falyx(program="fx", version="9.9.9")
+ flx.console = RecordingConsole()
+
+ await flx._render_version()
+
+ assert "fx v9.9.9" in flx.console.rendered
+
+
+def test_invalidate_prompt_session_cache_deletes_cached_property_value() -> None:
+ flx = make_falyx()
+ flx.__dict__["prompt_session"] = object()
+ flx._prompt_session = object()
+
+ flx._invalidate_prompt_session_cache()
+
+ assert "prompt_session" not in flx.__dict__
+ assert flx._prompt_session is None
+
+
+def test_bottom_bar_accepts_instance_string_callable_and_rejects_invalid() -> None:
+ flx = make_falyx()
+ existing_bottom_bar = flx.bottom_bar
+
+ flx.bottom_bar = existing_bottom_bar
+ assert flx.bottom_bar is existing_bottom_bar
+ assert flx.bottom_bar.key_bindings is flx.key_bindings
+
+ flx.bottom_bar = "static toolbar"
+ assert flx._get_bottom_bar_render() == "static toolbar"
+
+ renderer = lambda: "dynamic toolbar"
+ flx.bottom_bar = renderer
+ assert flx._get_bottom_bar_render() is renderer
+
+ with pytest.raises(FalyxError, match="bottom_bar"):
+ flx.bottom_bar = object()
+
+
+def test_default_bottom_bar_render_is_returned_when_items_exist() -> None:
+ flx = make_falyx()
+ render = flx._get_bottom_bar_render()
+
+ if flx.bottom_bar.has_items:
+ assert render is flx.bottom_bar.render
+ else:
+ assert render is None
+
+
+def test_register_all_hooks_rejects_non_callable_hook() -> None:
+ flx = make_falyx()
+
+ with pytest.raises(InvalidHookError, match="callable"):
+ flx.register_all_hooks(HookType.BEFORE, object())
+
+
+def test_validate_command_aliases_rejects_duplicate_aliases() -> None:
+ flx = make_falyx()
+
+ with pytest.raises(CommandAlreadyExistsError, match="duplicate aliases"):
+ flx.add_command(
+ "D", description="Deploy", action=lambda: None, aliases=["deploy", "DEPLOY"]
+ )
+
+
+def test_validate_command_aliases_rejects_key_as_alias() -> None:
+ flx = make_falyx()
+
+ with pytest.raises(CommandAlreadyExistsError, match="cannot also be an alias"):
+ flx.add_command("D", description="Deploy", action=lambda: None, aliases=["D"])
+
+
+def test_validate_command_aliases_rejects_existing_identifier_collision() -> None:
+ flx = make_falyx()
+
+ with pytest.raises(CommandAlreadyExistsError, match="already exist"):
+ flx.add_command("H", description="Duplicate Help", action=lambda: None)
+
+
+def test_update_exit_command_rejects_non_callable_action() -> None:
+ flx = make_falyx()
+
+ with pytest.raises(InvalidActionError, match="callable"):
+ flx.update_exit_command(key="Q", action="quit")
+
+
+def test_add_submenu_rejects_non_falyx_submenu() -> None:
+ flx = make_falyx()
+
+ with pytest.raises(NotAFalyxError, match="submenu"):
+ flx.add_submenu("OPS", "Operations", object())
+
+
+def test_add_commands_accepts_dicts_and_command_instances() -> None:
+ flx = make_falyx()
+ reusable = Command(key="B", description="Build", action=lambda: "built")
+
+ commands = flx.add_commands(
+ [
+ {"key": "D", "description": "Deploy", "action": lambda: "deployed"},
+ reusable,
+ ]
+ )
+
+ assert [command.key for command in commands] == ["D", "B"]
+ assert flx.commands["D"].description == "Deploy"
+ assert flx.commands["B"].description == "Build"
+
+
+def test_add_commands_rejects_invalid_items() -> None:
+ flx = make_falyx()
+
+ with pytest.raises(FalyxError, match="dictionary or an instance of Command"):
+ flx.add_commands([object()])
+
+
+def test_add_command_from_command_rejects_non_command() -> None:
+ flx = make_falyx()
+
+ with pytest.raises(FalyxError, match="instance of Command"):
+ flx.add_command_from_command(object())
+
+
+def test_iter_visible_entries_can_include_builtins() -> None:
+ flx = make_falyx()
+ visible = flx._iter_visible_entries(include_builtins=True)
+
+ assert any(entry.key == "H" for entry in visible)
+ assert any(entry.key == "PVW" for entry in visible)
+ assert any(entry.key == "VER" for entry in visible)
+
+
+def test_build_placeholder_menu_returns_empty_placeholder_without_user_commands() -> None:
+ flx = make_falyx()
+
+ assert flx.build_placeholder_menu() == [("", "")]
+
+
+def test_table_uses_callable_custom_table_and_rejects_invalid_factory() -> None:
+ good = make_falyx(custom_table=lambda app: Table(title=app.title))
+ assert isinstance(good.table, Table)
+
+ bad = make_falyx(custom_table=lambda app: "not a table")
+ with pytest.raises(FalyxError, match="custom_table"):
+ _ = bad.table
+
+
+def test_table_uses_prebuilt_custom_table_instance() -> None:
+ table = Table(title="Prebuilt")
+ flx = make_falyx(custom_table=table)
+
+ assert flx.table is table
+
+
+def test_resolve_entry_accepts_unique_prefix_matches() -> None:
+ flx = make_falyx()
+ command = add_deploy(flx, key="DEPLOY", aliases=[])
+
+ entry, suggestions = flx.resolve_entry("depl")
+
+ assert entry is command
+ assert suggestions == []
+
+
+@pytest.mark.asyncio
+async def test_prepare_route_converts_bad_shell_string_to_validation_error() -> None:
+ flx = make_falyx()
+
+ with pytest.raises(ValidationError):
+ await flx.prepare_route('"unterminated', from_validate=True)
+
+
+@pytest.mark.asyncio
+async def test_prepare_route_converts_bad_shell_string_to_usage_error() -> None:
+ flx = make_falyx()
+
+ with pytest.raises(UsageError, match="No closing quotation"):
+ await flx.prepare_route('"unterminated')
+
+
+@pytest.mark.asyncio
+async def test_prepare_route_rejects_invalid_raw_argument_type() -> None:
+ flx = make_falyx()
+
+ with pytest.raises(AssertionError, match="Validator can only pass"):
+ await flx.prepare_route(object(), from_validate=True)
+
+ with pytest.raises(UsageError, match="raw_arguments"):
+ await flx.prepare_route(object())
+
+
+@pytest.mark.asyncio
+async def test_prepare_route_preserves_preview_route_without_resolving_command_args() -> (
+ None
+):
+ flx = make_falyx()
+ add_deploy(flx)
+
+ route, args, kwargs, execution_args = await flx.prepare_route("?D")
+
+ assert route.is_preview is True
+ assert route.kind is RouteKind.COMMAND
+ assert args == ()
+ assert kwargs == {}
+ assert execution_args == {}
+
+
+@pytest.mark.asyncio
+async def test_prepare_route_wraps_route_errors_for_validation(monkeypatch) -> None:
+ flx = make_falyx()
+
+ async def fake_resolve_route(*args, **kwargs):
+ raise FalyxError("bad route", hint="try deploy")
+
+ monkeypatch.setattr(flx, "resolve_route", fake_resolve_route)
+
+ with pytest.raises(ValidationError) as error:
+ await flx.prepare_route("D", from_validate=True)
+
+ assert "try deploy" in str(error.value)
+
+
+@pytest.mark.asyncio
+async def test_prepare_route_wraps_command_argument_errors_for_validation(
+ monkeypatch,
+) -> None:
+ flx = make_falyx()
+ command = add_deploy(flx)
+
+ async def fake_resolve_route(*args, **kwargs):
+ return route_for(flx, RouteKind.COMMAND, command=command, leaf_argv=["--bad"])
+
+ async def fake_resolve_args(*args, **kwargs):
+ raise CommandArgumentError("bad args", hint="use --help")
+
+ monkeypatch.setattr(flx, "resolve_route", fake_resolve_route)
+ monkeypatch.setattr(Command, "resolve_args", fake_resolve_args)
+
+ with pytest.raises(ValidationError) as error:
+ await flx.prepare_route(["D"], from_validate=True)
+
+ assert "use --help" in str(error.value)
+
+
+@pytest.mark.asyncio
+async def test_render_unknown_route_rejects_preview_namespace_menu() -> None:
+ flx = make_falyx()
+ route = route_for(flx, RouteKind.NAMESPACE_MENU)
+
+ with pytest.raises(FalyxError, match="preview mode"):
+ await flx._render_unknown_route(route)
+
+
+@pytest.mark.asyncio
+async def test_dispatch_route_previews_command_and_unknown_preview(monkeypatch) -> None:
+ flx = make_falyx()
+ command = SimpleNamespace(key="D", preview=AsyncMock())
+ command_route = route_for(
+ flx,
+ RouteKind.COMMAND,
+ command=command,
+ is_preview=True,
+ )
+
+ await flx._dispatch_route(route=command_route)
+ command.preview.assert_awaited_once()
+
+ unknown_route = route_for(
+ flx, RouteKind.UNKNOWN, current_head="missing", is_preview=True
+ )
+ rendered: list[RouteResult] = []
+
+ async def fake_unknown(route):
+ rendered.append(route)
+
+ monkeypatch.setattr(flx, "_render_unknown_route", fake_unknown)
+
+ await flx._dispatch_route(route=unknown_route)
+ assert rendered == [unknown_route]
+
+
+@pytest.mark.asyncio
+async def test_dispatch_route_unknown_returns_after_rendering(monkeypatch) -> None:
+ flx = make_falyx()
+ route = route_for(flx, RouteKind.UNKNOWN, current_head="missing")
+ rendered: list[RouteResult] = []
+
+ async def fake_unknown(route):
+ rendered.append(route)
+
+ monkeypatch.setattr(flx, "_render_unknown_route", fake_unknown)
+
+ assert await flx._dispatch_route(route=route) is None
+ assert rendered == [route]
+
+
+@pytest.mark.asyncio
+async def test_dispatch_route_rejects_command_route_without_command() -> None:
+ flx = make_falyx()
+ route = route_for(flx, RouteKind.COMMAND, command=None)
+
+ with pytest.raises(FalyxError, match="command expected"):
+ await flx._dispatch_route(route=route)
+
+
+@pytest.mark.asyncio
+async def test_execute_command_requires_error_policy() -> None:
+ flx = make_falyx()
+
+ with pytest.raises(FalyxError, match="requires either"):
+ await flx.execute_command("D", raise_on_error=False, wrap_errors=False)
+
+
+def test_resolve_completion_route_returns_entry_completion_for_unknown_committed_token() -> (
+ None
+):
+ flx = make_falyx()
+ context = flx.get_current_invocation_context()
+
+ route = flx.resolve_completion_route(
+ ["depoy"],
+ stub="",
+ cursor_at_end_of_token=False,
+ invocation_context=context,
+ )
+
+ assert route.expecting_entry is True
+ assert route.stub == "depoy"
+ assert route.command is None
+
+
+@pytest.mark.asyncio
+async def test_process_command_executes_prompt_input_and_reports_falyx_error(
+ monkeypatch,
+) -> None:
+ flx = make_falyx()
+ errors: list[object] = []
+ invalidated: list[bool] = []
+
+ class FakeApp:
+ def invalidate(self) -> None:
+ invalidated.append(True)
+
+ class FakeSession:
+ async def prompt_async(self) -> str:
+ return "D"
+
+ async def fake_execute_command(*args, **kwargs):
+ raise FalyxError("boom")
+
+ monkeypatch.setattr(falyx_module, "get_app", lambda: FakeApp())
+ monkeypatch.setattr(falyx_module.asyncio, "sleep", AsyncMock())
+ monkeypatch.setattr(falyx_module, "patch_stdout", lambda raw=True: nullcontext())
+ monkeypatch.setattr(
+ falyx_module, "print_error", lambda message, **_: errors.append(message)
+ )
+ monkeypatch.setattr(flx, "execute_command", fake_execute_command)
+ flx.__dict__["prompt_session"] = FakeSession()
+
+ await flx._process_command()
+
+ assert invalidated == [True]
+ assert isinstance(errors[0], FalyxError)
+
+
+@pytest.mark.asyncio
+async def test_menu_handles_flow_signals_and_prints_welcome_and_exit(monkeypatch) -> None:
+ rendered: list[Falyx] = []
+ flx = make_falyx(
+ welcome_message="welcome",
+ exit_message="goodbye",
+ render_menu=lambda app: rendered.append(app),
+ )
+ flx.console = RecordingConsole()
+ signals: list[BaseException] = [
+ HelpSignal(),
+ BackSignal(),
+ CancelSignal(),
+ asyncio.CancelledError(),
+ QuitSignal(),
+ ]
+
+ async def fake_process_command() -> None:
+ raise signals.pop(0)
+
+ monkeypatch.setattr(flx, "_process_command", fake_process_command)
+
+ await flx.menu()
+
+ assert rendered == [flx, flx, flx, flx, flx]
+ assert "welcome" in flx.console.rendered
+ assert "goodbye" in flx.console.rendered
+
+
+@pytest.mark.asyncio
+async def test_run_logs_verbose_unhandled_errors_before_exit(monkeypatch) -> None:
+ flx = make_falyx()
+ flx.options_manager.set("verbose", True, "root")
+ context = flx.get_current_invocation_context()
+ route = RouteResult(kind=RouteKind.NAMESPACE_MENU, namespace=flx, context=context)
+ logged: list[tuple[tuple, dict]] = []
+
+ async def fake_prepare_route(*args, **kwargs):
+ return route, (), {}, {}
+
+ async def fake_dispatch_route(*args, **kwargs):
+ raise FalyxError("boom")
+
+ monkeypatch.setattr(flx, "prepare_route", fake_prepare_route)
+ monkeypatch.setattr(flx, "_dispatch_route", fake_dispatch_route)
+ monkeypatch.setattr(falyx_module.sys, "argv", ["fx", "D"])
+ monkeypatch.setattr(falyx_module, "print_error", lambda message, **_: None)
+ monkeypatch.setattr(
+ falyx_module.logger,
+ "error",
+ lambda *args, **kwargs: logged.append((args, kwargs)),
+ )
+
+ with pytest.raises(SystemExit) as error:
+ await flx.run()
+
+ assert error.value.code == 1
+ assert logged
+ assert logged[0][1]["exc_info"] is True
diff --git a/tests/test_falyx/test_help.py b/tests/test_falyx/test_help.py
index 9431e37..6f8f3c7 100644
--- a/tests/test_falyx/test_help.py
+++ b/tests/test_falyx/test_help.py
@@ -1,6 +1,8 @@
import pytest
+from rich.text import Text
from falyx import Falyx
+from falyx.exceptions import CommandArgumentError
@pytest.mark.asyncio
@@ -8,7 +10,7 @@ async def test_help_command(capsys):
flx = Falyx()
assert flx.help_command.arg_parser.aliases[0] == "HELP"
assert flx.help_command.arg_parser.command_key == "H"
- await flx.run_key("H")
+ await flx.execute_command("H")
captured = capsys.readouterr()
assert "Show this help menu" in captured.out
@@ -28,7 +30,7 @@ async def test_help_command_with_new_command(capsys):
aliases=["TEST"],
help_text="This is a new command.",
)
- await flx.run_key("H")
+ await flx.execute_command("H")
captured = capsys.readouterr()
assert "This is a new command." in captured.out
@@ -49,7 +51,7 @@ async def test_render_help(capsys):
aliases=["SC"],
help_text="This is a sample command.",
)
- await flx._render_help()
+ await flx.render_help()
captured = capsys.readouterr()
assert "This is a sample command." in captured.out
@@ -70,27 +72,24 @@ async def test_help_command_by_tag(capsys):
tags=["tag1"],
help_text="This command is tagged.",
)
- await flx.run_key("H", args=("tag1",))
+ await flx.execute_command("H -t tag1")
captured = capsys.readouterr()
- assert "tag1" in captured.out
- assert "This command is tagged." in captured.out
- assert "HELP" not in captured.out
+ text = Text.from_ansi(captured.out)
+ assert "tag1" in text.plain
+ assert "This command is tagged." in text.plain
+ assert "HELP" not in text.plain
@pytest.mark.asyncio
-async def test_help_command_empty_tags(capsys):
+async def test_help_command_bad_argument(capsys):
flx = Falyx()
async def untagged_command(falyx: Falyx):
pass
- flx.add_command(
- "U", "Untagged Command", untagged_command, help_text="This command has no tags."
- )
- await flx.run_key("H", args=("nonexistent_tag",))
-
- captured = capsys.readouterr()
- print(captured.out)
- assert "nonexistent_tag" in captured.out
- assert "Nothing to show here" in captured.out
+ 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")
diff --git a/tests/test_falyx/test_options_manager_contract.py b/tests/test_falyx/test_options_manager_contract.py
new file mode 100644
index 0000000..c81b8c2
--- /dev/null
+++ b/tests/test_falyx/test_options_manager_contract.py
@@ -0,0 +1,219 @@
+import pytest
+
+from falyx import Falyx
+from falyx.action import Action, ChainedAction
+from falyx.command import Command
+from falyx.options_manager import OptionsManager
+
+
+def test_seed_missing_and_override_namespace_do_not_leak():
+ options = OptionsManager()
+ options.set("verbose", True, "root")
+
+ options.seed_missing({"verbose": False, "debug_hooks": False}, "root")
+ assert options.get("verbose", namespace_name="root") is True
+ assert options.get("debug_hooks", namespace_name="root") is False
+
+ with options.override_namespace({"verbose": False}, "root"):
+ assert options.get("verbose", namespace_name="root") is False
+
+ assert options.get("verbose", namespace_name="root") is True
+
+
+def test_command_and_action_read_options_from_expected_namespace():
+ options = OptionsManager()
+ options.from_mapping({"region": "us-east"}, "default")
+ options.from_mapping({"never_prompt": True, "verbose": True}, "root")
+
+ action = Action("deploy-action", lambda: "ok")
+ command = Command.build(
+ key="D",
+ description="Deploy",
+ action=action,
+ options_manager=options,
+ )
+
+ command._inject_options_manager()
+
+ assert command.get_option("region") == "us-east"
+ assert command.get_option("verbose", namespace_name="root") is True
+
+ assert action.get_option("region") == "us-east"
+ assert action.get_option("verbose", namespace_name="root") is True
+ assert action.never_prompt is True
+ assert action.local_never_prompt is None
+
+
+def test_all_objects_in_one_namespace_share_same_options_manager():
+ flx = Falyx(program="root")
+
+ chain = ChainedAction(
+ name="deploy-flow",
+ actions=[
+ Action("step-one", lambda: "one"),
+ Action("step-two", lambda: "two"),
+ ],
+ )
+ command = flx.add_command("D", "Deploy", action=chain)
+
+ command._inject_options_manager()
+
+ assert flx._executor.options_manager is flx.options_manager
+ assert flx.exit_command.options_manager is flx.options_manager
+ assert flx.help_command.options_manager is flx.options_manager
+
+ if flx.history_command:
+ assert flx.history_command.options_manager is flx.options_manager
+ assert flx.history_command.arg_parser.options_manager is flx.options_manager
+
+ for builtin in flx.builtins.values():
+ assert builtin.options_manager is flx.options_manager
+ if builtin.arg_parser:
+ assert builtin.arg_parser.options_manager is flx.options_manager
+
+ assert command.options_manager is flx.options_manager
+ assert command.arg_parser.options_manager is flx.options_manager
+
+ assert chain.options_manager is flx.options_manager
+ for child_action in chain.actions:
+ assert child_action.options_manager is flx.options_manager
+
+
+def test_nested_namespace_may_keep_distinct_options_manager_if_intended():
+ root_options = OptionsManager()
+ child_options = OptionsManager()
+
+ root = Falyx(program="root", options_manager=root_options)
+ child = Falyx(program="child", options_manager=child_options)
+ child_command = child.add_command("D", "Deploy", action=lambda: "ok")
+
+ root.add_submenu(
+ key="C",
+ description="Child Menu",
+ submenu=child,
+ )
+
+ assert root.options_manager is root_options
+ assert child.options_manager is child_options
+ assert root.options_manager is not child.options_manager
+
+ assert root._executor.options_manager is root_options
+ assert child._executor.options_manager is child_options
+
+ assert child_command.options_manager is child_options
+ assert child_command.arg_parser.options_manager is child_options
+
+ assert child.exit_command.options_manager is child_options
+ assert child.help_command.options_manager is child_options
+ if child.history_command:
+ assert child.history_command.options_manager is child_options
+
+ assert root.namespaces["C"].namespace is child
+
+
+@pytest.mark.asyncio
+async def test_nested_namespace_receives_temporary_root_overrides_during_routed_execution():
+ root_options = OptionsManager()
+ child_options = OptionsManager()
+
+ root = Falyx(program="root", options_manager=root_options)
+ child = Falyx(program="child", options_manager=child_options)
+ child.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"])
+
+ child.options_manager.set("verbose", False, "root")
+
+ root.add_submenu(
+ key="C",
+ description="Child Menu",
+ submenu=child,
+ )
+
+ seen_during_dispatch = {}
+
+ async def fake_dispatch_route(*, route, args, kwargs, execution_args, **_):
+ assert route.namespace is child
+ seen_during_dispatch["verbose"] = route.namespace.options_manager.get(
+ "verbose", False, "root"
+ )
+ assert seen_during_dispatch["verbose"] is True
+ return "ok"
+
+ root._dispatch_route = fake_dispatch_route
+
+ result = await root.execute_command("--verbose C D")
+
+ assert result == "ok"
+ assert seen_during_dispatch["verbose"] is True
+
+ result = await root.execute_command("C --verbose D")
+
+ assert result == "ok"
+ assert seen_during_dispatch["verbose"] is True
+
+ assert child.options_manager is child_options
+ assert child.options_manager.get("verbose", False, "root") is False
+ assert root.options_manager is root_options
+
+
+@pytest.mark.asyncio
+async def test_execute_command_applies_root_defaults_without_overwriting_existing_root_values():
+ child = Falyx(program="child")
+ child.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"])
+
+ child.options_manager.set("verbose", True, "root")
+
+ root = Falyx(program="root")
+ root.add_submenu(
+ key="C",
+ description="Child Menu",
+ submenu=child,
+ )
+
+ async def fake_dispatch_route(*, route, args, kwargs, execution_args, **_):
+ assert route.namespace is child
+ assert route.root_overrides == {}
+ assert route.root_defaults["verbose"] is False
+ assert route.namespace.options_manager.get("verbose", False, "root") is True
+ return "ok"
+
+ root._dispatch_route = fake_dispatch_route
+
+ result = await root.execute_command("C D")
+
+ assert result == "ok"
+ assert child.options_manager.get("verbose", False, "root") is True
+
+
+@pytest.mark.asyncio
+async def test_execute_command_applies_root_overrides_temporarily_and_restores_root_namespace():
+ child = Falyx(program="child")
+ child.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"])
+
+ child.options_manager.set("verbose", False, "root")
+
+ root = Falyx(program="root")
+ root.add_submenu(
+ key="C",
+ description="Child Menu",
+ submenu=child,
+ )
+
+ seen_during_dispatch = {}
+
+ async def fake_dispatch_route(*, route, args, kwargs, execution_args, **_):
+ assert route.namespace is child
+ assert route.root_overrides == {"verbose": True}
+ seen_during_dispatch["verbose"] = route.namespace.options_manager.get(
+ "verbose", False, "root"
+ )
+ assert seen_during_dispatch["verbose"] is True
+ return "ok"
+
+ root._dispatch_route = fake_dispatch_route
+
+ result = await root.execute_command("--verbose C D")
+
+ assert result == "ok"
+ assert seen_during_dispatch["verbose"] is True
+
+ assert child.options_manager.get("verbose", False, "root") is False
diff --git a/tests/test_falyx/test_prompt_contract.py b/tests/test_falyx/test_prompt_contract.py
new file mode 100644
index 0000000..57295f4
--- /dev/null
+++ b/tests/test_falyx/test_prompt_contract.py
@@ -0,0 +1,21 @@
+from falyx.action import Action
+from falyx.command import Command
+
+
+async def test_action_local_never_prompt_bypasses_command_confirmation(monkeypatch):
+ called = False
+
+ async def fake_confirm(*args, **kwargs):
+ nonlocal called
+ called = True
+ return True
+
+ monkeypatch.setattr("falyx.command.confirm_async", fake_confirm)
+
+ action = Action("Do Thing", lambda: "ok", never_prompt=True)
+ command = Command.build("D", "Do Thing", action=action, confirm=True)
+
+ result = await command()
+
+ assert result == "ok"
+ assert called is False
diff --git a/tests/test_falyx/test_routing.py b/tests/test_falyx/test_routing.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_falyx/test_routing_contract.py b/tests/test_falyx/test_routing_contract.py
new file mode 100644
index 0000000..0e83ea7
--- /dev/null
+++ b/tests/test_falyx/test_routing_contract.py
@@ -0,0 +1,92 @@
+import pytest
+
+from falyx import Falyx
+from falyx.routing import RouteKind
+
+
+@pytest.mark.asyncio
+async def test_resolve_route_carries_root_options_through_nested_namespace():
+ child = Falyx(program="child")
+ child.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"])
+
+ root = Falyx(program="root")
+ root.add_submenu(
+ key="C",
+ description="Child Menu",
+ submenu=child,
+ )
+
+ route = await root.resolve_route(
+ ["--verbose", "C", "D"],
+ invocation_context=root.get_current_invocation_context(),
+ )
+
+ assert route.context.typed_path[-2:] == ["C", "D"]
+
+ assert route.kind is RouteKind.COMMAND
+ assert route.namespace is child
+ assert route.command is child.commands["D"]
+ assert route.leaf_argv == []
+
+ assert route.root_overrides == {"verbose": True}
+ assert route.root_defaults["verbose"] is False
+ assert route.root_defaults["debug_hooks"] is False
+ assert route.root_defaults["never_prompt"] is False
+
+ assert route.namespace_overrides == {}
+
+
+@pytest.mark.asyncio
+async def test_resolve_route_returns_unknown_when_only_namespace_options_are_provided():
+ flx = Falyx(program="falyx")
+ flx.add_option("--profile", default="dev")
+
+ route = await flx.resolve_route(
+ ["--profile", "prod"],
+ invocation_context=flx.get_current_invocation_context(),
+ )
+
+ assert route.kind is RouteKind.UNKNOWN
+ assert route.namespace is flx
+ assert route.command is None
+ assert route.current_head == ""
+ assert route.is_preview is False
+ assert route.root_defaults == {}
+ assert route.root_overrides == {}
+ assert route.namespace_defaults == {}
+ assert route.namespace_overrides == {}
+
+
+@pytest.mark.asyncio
+async def test_resolve_route_returns_unknown_when_only_root_options_are_provided():
+ flx = Falyx(program="falyx")
+
+ route = await flx.resolve_route(
+ ["--verbose"],
+ invocation_context=flx.get_current_invocation_context(),
+ )
+
+ assert route.kind is RouteKind.UNKNOWN
+ assert route.namespace is flx
+ assert route.command is None
+ assert route.current_head == ""
+ assert route.is_preview is False
+
+
+@pytest.mark.asyncio
+async def test_resolve_route_returns_unknown_when_nested_namespace_consumes_only_options():
+ child = Falyx(program="child")
+ child.add_option("--region", default="us-east")
+
+ root = Falyx(program="root")
+ root.add_submenu(key="C", description="Child", submenu=child)
+
+ route = await root.resolve_route(
+ ["C", "--region", "us-west"],
+ invocation_context=root.get_current_invocation_context(),
+ )
+
+ assert route.kind is RouteKind.UNKNOWN
+ assert route.namespace is child
+ assert route.command is None
+ assert route.context.typed_path[-1] == "C"
diff --git a/tests/test_falyx/test_run.py b/tests/test_falyx/test_run.py
index ac83d30..b12e88d 100644
--- a/tests/test_falyx/test_run.py
+++ b/tests/test_falyx/test_run.py
@@ -1,19 +1,319 @@
+import asyncio
import sys
import pytest
+from rich.text import Text
from falyx import Falyx
-from falyx.parser import get_arg_parsers
+from falyx.console import console as falyx_console
+from falyx.exceptions import FalyxError
+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
async def test_run_basic(capsys):
- sys.argv = ["falyx", "run", "-h"]
- falyx_parsers = get_arg_parsers()
- assert falyx_parsers is not None, "Falyx parsers should be initialized"
+ sys.argv = ["falyx", "-h"]
flx = Falyx()
with pytest.raises(SystemExit):
- await flx.run(falyx_parsers)
+ await flx.run()
captured = capsys.readouterr()
- assert "Run a command by its key or alias." in captured.out
+ assert "Show this help menu." in captured.out
+
+
+@pytest.mark.asyncio
+async def test_run_default_to_menu(flx):
+ sys.argv = ["falyx", "T"]
+ flx.default_to_menu = False
+
+ with pytest.raises(SystemExit):
+ await flx.run()
+
+ await flx.run(always_start_menu=True)
+
+
+@pytest.mark.asyncio
+async def test_run_default_to_menu_help(flx):
+ sys.argv = ["falyx"]
+ flx.default_to_menu = False
+ with pytest.raises(SystemExit, match="0"):
+ with falyx_console.capture() as capture:
+ await flx.run()
+
+ captured = Text.from_ansi(capture.get()).plain
+ assert "Show this help menu." in captured
+
+
+@pytest.mark.asyncio
+async def test_run_debug_hooks(flx):
+ sys.argv = ["falyx", "--debug-hooks", "T"]
+
+ assert flx.options_manager.get("debug_hooks", namespace_name="root") is False
+
+ with pytest.raises(SystemExit):
+ await flx.run()
+
+ assert flx.options_manager.get("debug_hooks", namespace_name="root") is False
+
+
+@pytest.mark.asyncio
+async def test_run_never_prompt(flx):
+ sys.argv = ["falyx", "--never-prompt", "T"]
+
+ assert flx.options_manager.get("never_prompt", namespace_name="root") is False
+
+ with pytest.raises(SystemExit):
+ await flx.run()
+
+ assert flx.options_manager.get("debug_hooks", namespace_name="root") is False
+ assert flx.options_manager.get("never_prompt", namespace_name="root") is False
+
+
+@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: (args=(), kwargs={})" in captured
+
+
+@pytest.mark.asyncio
+async def test_run_applies_root_defaults_without_overwriting_existing_root_values():
+ child = Falyx(program="child")
+ child.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"])
+
+ child.options_manager.set("verbose", True, "root")
+
+ root = Falyx(program="root")
+ root.add_submenu(
+ key="C",
+ description="Child Menu",
+ submenu=child,
+ )
+
+ async def fake_dispatch_route(*, route, args, kwargs, execution_args, **_):
+ assert route.namespace is child
+ assert route.root_overrides == {}
+ assert route.root_defaults["verbose"] is False
+ assert route.namespace.options_manager.get("verbose", False, "root") is True
+
+ root._dispatch_route = fake_dispatch_route
+
+ sys.argv = ["falyx", "C", "D"]
+ with pytest.raises(SystemExit) as excinfo:
+ await root.run()
+
+ assert excinfo.value.code == 0
+
+ assert child.options_manager.get("verbose", False, "root") is True
+
+
+@pytest.mark.asyncio
+async def test_run_applies_root_overrides_temporarily_and_restores_root_namespace():
+ child = Falyx(program="child")
+ child.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"])
+
+ child.options_manager.set("verbose", False, "root")
+
+ root = Falyx(program="root")
+ root.add_submenu(
+ key="C",
+ description="Child Menu",
+ submenu=child,
+ )
+
+ seen_during_dispatch = {}
+
+ async def fake_dispatch_route(*, route, args, kwargs, execution_args, **_):
+ seen_during_dispatch["verbose"] = route.namespace.options_manager.get(
+ "verbose", False, "root"
+ )
+ assert route.namespace is child
+ assert route.root_overrides == {"verbose": True}
+ assert seen_during_dispatch["verbose"] is True
+
+ root._dispatch_route = fake_dispatch_route
+
+ sys.argv = ["falyx", "--verbose", "C", "D"]
+ with pytest.raises(SystemExit) as excinfo:
+ await root.run()
+
+ assert excinfo.value.code == 0
+ assert seen_during_dispatch["verbose"] is True
+
+ assert child.options_manager.get("verbose", False, "root") is False
diff --git a/tests/test_falyx/test_signals.py b/tests/test_falyx/test_signals.py
new file mode 100644
index 0000000..307dfcf
--- /dev/null
+++ b/tests/test_falyx/test_signals.py
@@ -0,0 +1,15 @@
+import sys
+
+import pytest
+
+from falyx import Falyx
+
+
+async def test_run_quit_signal_exits_130(monkeypatch):
+ flx = Falyx(default_to_menu=False)
+ monkeypatch.setattr(sys, "argv", ["prog", "X"])
+
+ with pytest.raises(SystemExit) as exc:
+ await flx.run()
+
+ assert exc.value.code == 130
diff --git a/tests/test_falyx_parser/test_falyx_parser.py b/tests/test_falyx_parser/test_falyx_parser.py
new file mode 100644
index 0000000..3c28a52
--- /dev/null
+++ b/tests/test_falyx_parser/test_falyx_parser.py
@@ -0,0 +1,900 @@
+from __future__ import annotations
+
+import pytest
+
+from falyx import Falyx
+from falyx.exceptions import EntryNotFoundError, FalyxOptionError
+from falyx.mode import FalyxMode
+from falyx.parser.falyx_parser import FalyxParser
+from falyx.parser.option import Option
+from falyx.parser.parser_types import FalyxTLDRExample
+
+
+@pytest.fixture
+def flx() -> Falyx:
+ flx = Falyx()
+ flx.add_command(
+ "D",
+ description="Deploy command",
+ action=lambda: "deploy",
+ aliases=["deploy"],
+ )
+ return flx
+
+
+@pytest.fixture
+def parser(flx: Falyx) -> FalyxParser:
+ return FalyxParser(flx)
+
+
+def test_init_registers_reserved_options_by_default(parser: FalyxParser) -> None:
+ flags = parser.get_flags()
+
+ assert "-h" in flags
+ assert "-v" in flags
+ assert "-d" in flags
+ assert "-n" in flags
+ assert parser.help_option is not None
+ assert parser.tldr_option is None
+
+
+def test_init_respects_disabled_reserved_root_options() -> None:
+ parser = FalyxParser(
+ Falyx(
+ disable_verbose_option=True,
+ disable_debug_hooks_option=True,
+ disable_never_prompt_option=True,
+ )
+ )
+
+ assert parser.get_flags() == ["-h"]
+
+ with pytest.raises(FalyxOptionError, match="unknown option '-v'"):
+ parser.parse_args(["-v"])
+
+
+def test_get_options_returns_registered_options(parser: FalyxParser) -> None:
+ parser.add_option("--region", "-r", default="us-east")
+
+ options = parser.get_options()
+
+ assert any(option.dest == "region" for option in options)
+ assert parser.get_flags()[-1] == "--region"
+
+
+def test_add_option_registers_store_option_with_default_and_choices(
+ parser: FalyxParser,
+) -> None:
+ parser.add_option(
+ "--region",
+ "-r",
+ default="us-east",
+ choices=["us-east", "us-west"],
+ )
+
+ result = parser.parse_args(["--region", "us-west", "deploy"])
+
+ assert result.namespace_options["region"] == "us-west"
+ assert result.namespace_defaults["region"] == "us-east"
+ assert result.remaining_argv == ["deploy"]
+
+
+def test_add_option_infers_dest_from_long_flag(parser: FalyxParser) -> None:
+ parser.add_option("--dry-run-mode", default="safe")
+
+ result = parser.parse_args([])
+
+ assert result.namespace_defaults["dry_run_mode"] == "safe"
+
+
+def test_add_option_uses_explicit_dest(parser: FalyxParser) -> None:
+ parser.add_option("--profile-name", dest="profile", default="dev")
+
+ result = parser.parse_args(["--profile-name", "prod"])
+
+ assert result.namespace_options["profile"] == "prod"
+
+
+@pytest.mark.parametrize(
+ ("flags", "match"),
+ [
+ ((), "no flags provided"),
+ (("region",), "must start with '-'"),
+ (("--",), "long flags must have at least one character"),
+ (("-abc",), "short flags must be a single character"),
+ ],
+)
+def test_add_option_rejects_invalid_flags(
+ parser: FalyxParser,
+ flags: tuple[str, ...],
+ match: str,
+) -> None:
+ with pytest.raises(FalyxOptionError, match=match):
+ parser.add_option(*flags)
+
+
+@pytest.mark.parametrize("dest", ["help", "tldr"])
+def test_add_option_rejects_reserved_dests(
+ parser: FalyxParser,
+ dest: str,
+) -> None:
+ with pytest.raises(FalyxOptionError, match="reserved"):
+ parser.add_option("--custom", dest=dest)
+
+
+def test_add_option_rejects_duplicate_dest(parser: FalyxParser) -> None:
+ parser.add_option("--region")
+
+ with pytest.raises(FalyxOptionError, match="duplicate option dest 'region'"):
+ parser.add_option("--region-name", dest="region")
+
+
+def test_add_option_rejects_duplicate_flag(parser: FalyxParser) -> None:
+ parser.add_option("--region")
+
+ with pytest.raises(FalyxOptionError, match="already used"):
+ parser.add_option("--region", dest="other_region")
+
+
+@pytest.mark.parametrize(
+ ("dest", "match"),
+ [
+ ("bad-dest", "valid identifier"),
+ ("1bad", "cannot start with a digit"),
+ ],
+)
+def test_add_option_rejects_invalid_explicit_dest(
+ parser: FalyxParser,
+ dest: str,
+ match: str,
+) -> None:
+ with pytest.raises(FalyxOptionError, match=match):
+ parser.add_option("--valid", dest=dest)
+
+
+def test_add_option_rejects_invalid_action(parser: FalyxParser) -> None:
+ with pytest.raises(FalyxOptionError, match="invalid option action"):
+ parser.add_option("--region", action="not-real")
+
+
+def test_add_option_rejects_invalid_store_true_default(parser: FalyxParser) -> None:
+ with pytest.raises(FalyxOptionError, match="must be False or None"):
+ parser.add_option("--foo", action="store_true", default=True)
+
+
+def test_add_option_rejects_invalid_store_false_default(parser: FalyxParser) -> None:
+ with pytest.raises(FalyxOptionError, match="must be True or None"):
+ parser.add_option("--foo", action="store_false", default=False)
+
+
+def test_add_option_rejects_invalid_store_bool_optional_default(
+ parser: FalyxParser,
+) -> None:
+ with pytest.raises(
+ FalyxOptionError,
+ match="default value for 'store_bool_optional' action must be None",
+ ):
+ parser.add_option("--foo", action="store_bool_optional", default="not-bool")
+
+
+def test_add_option_rejects_default_for_help_or_tldr_option(parser: FalyxParser) -> None:
+ with pytest.raises(
+ FalyxOptionError, match="default value cannot be set for action 'help'"
+ ):
+ parser.add_option("--additional-help", action="help", default=True)
+
+ with pytest.raises(
+ FalyxOptionError, match="default value cannot be set for action 'tldr'"
+ ):
+ parser.add_option("--more-tldr", action="tldr", default=True)
+
+
+def test_add_option_rejects_choices_for_boolean_option(parser: FalyxParser) -> None:
+ with pytest.raises(FalyxOptionError, match="choices cannot be specified"):
+ parser.add_option("--foo", action="store_true", choices=["yes"])
+
+
+def test_add_option_rejects_default_outside_choices(parser: FalyxParser) -> None:
+ with pytest.raises(FalyxOptionError, match="not in allowed choices"):
+ parser.add_option(
+ "--region",
+ default="eu-central",
+ choices=["us-east", "us-west"],
+ )
+
+
+def test_add_option_rejects_invalid_default_type(parser: FalyxParser) -> None:
+ with pytest.raises(FalyxOptionError, match="cannot be coerced to int"):
+ parser.add_option("--retries", type=int, default="not-an-int")
+
+
+def test_add_option_rejects_invalid_choice_type(parser: FalyxParser) -> None:
+ with pytest.raises(FalyxOptionError, match="invalid choice"):
+ parser.add_option("--retries", type=int, choices=["1", "bad"])
+
+
+def test_add_option_rejects_non_list_suggestions(parser: FalyxParser) -> None:
+ with pytest.raises(FalyxOptionError, match="suggestions must be a list or None"):
+ parser.add_option("--profile", suggestions=("dev", "prod"))
+
+
+def test_add_option_rejects_non_string_suggestions(parser: FalyxParser) -> None:
+ with pytest.raises(FalyxOptionError, match="suggestions must be a list of strings"):
+ parser.add_option("--profile", suggestions=["dev", 1])
+
+
+def test_add_option_rejects_non_string_flags(parser: FalyxParser) -> None:
+ with pytest.raises(FalyxOptionError, match="invalid flag '123': must be a string"):
+ parser.add_option("--region", 123)
+
+
+def test_add_option_rejects_flags_with_invalid_prefix(parser: FalyxParser) -> None:
+ with pytest.raises(
+ FalyxOptionError, match="invalid flag 'region': must start with '-'"
+ ):
+ parser.add_option("region")
+
+
+def test_add_option_rejects_long_flag_with_insufficient_length(
+ parser: FalyxParser,
+) -> None:
+ with pytest.raises(
+ FalyxOptionError, match="long flags must have at least one character after '--'"
+ ):
+ parser.add_option("--", dest="invalid")
+
+
+def test_add_option_rejects_speacial_characters_in_dest(parser: FalyxParser) -> None:
+ with pytest.raises(
+ FalyxOptionError, match="invalid dest 'bad-dest': must be a valid identifier"
+ ):
+ parser.add_option("--bad-dest", dest="bad-dest")
+
+ with pytest.raises(
+ FalyxOptionError, match=r"invalid dest 'bad\*dest': must be a valid identifier"
+ ):
+ parser.add_option("--bad-dest", dest="bad*dest")
+
+ with pytest.raises(
+ FalyxOptionError, match="invalid dest '1bad-dest': must be a valid identifier"
+ ):
+ parser.add_option("--bad-dest", dest="1bad-dest")
+
+
+def test_add_option_rejects_dest_starting_with_digit(parser: FalyxParser) -> None:
+ with pytest.raises(
+ FalyxOptionError, match="invalid dest '1bad': cannot start with a digit"
+ ):
+ parser.add_option("--1bad", dest="1bad")
+
+
+def test_add_option_rejects_special_characters_in_flags(parser: FalyxParser) -> None:
+ with pytest.raises(
+ FalyxOptionError,
+ match=r"invalid flag '--bad\*flag': must only contain letters, digits, underscores, or hyphens",
+ ):
+ parser.add_option("--bad*flag", dest="bad_flag")
+
+
+def test_add_option_rejects_short_flag_with_multiple_characters(
+ parser: FalyxParser,
+) -> None:
+ with pytest.raises(
+ FalyxOptionError,
+ match="invalid flag '-ab': short flags must be a single character",
+ ):
+ parser.add_option("-ab")
+
+
+def test_add_option_rejects_bad_flags(parser: FalyxParser) -> None:
+ with pytest.raises(
+ FalyxOptionError,
+ match="--region1@': must only contain letters, digits, underscores, or hyphens",
+ ):
+ parser.add_option("--region1@")
+
+ with pytest.raises(
+ FalyxOptionError, match="invalid dest '42region': cannot start with a digit"
+ ):
+ parser.add_option("--42region")
+
+
+def test_register_option_rejects_duplicate_flag(parser: FalyxParser) -> None:
+ parser.add_option("--region", "-r")
+ parser.add_option("--profile", "-p")
+
+ option1 = Option(flags=("--region", "-r"), dest="region")
+ option2 = Option(flags=("--profile", "-p"), dest="profile")
+
+ with pytest.raises(FalyxOptionError, match="already used"):
+ parser._register_option(option1)
+
+ with pytest.raises(FalyxOptionError, match="already used"):
+ parser._register_option(option2)
+
+
+def test_parse_args_with_no_args_returns_defaults(parser: FalyxParser) -> None:
+ result = parser.parse_args([])
+
+ assert result.mode is FalyxMode.COMMAND
+ assert result.raw_argv == []
+ assert result.remaining_argv == []
+ assert result.current_head == ""
+ assert result.help is False
+ assert result.tldr is False
+ assert result.namespace_defaults["help"] is False
+ assert result.root_defaults["verbose"] is False
+ assert result.root_defaults["debug_hooks"] is False
+ assert result.root_defaults["never_prompt"] is False
+
+
+def test_parse_args_splits_root_and_namespace_options(parser: FalyxParser) -> None:
+ parser.add_option("--profile", default="dev")
+
+ result = parser.parse_args(["--verbose", "--profile", "prod", "deploy"])
+
+ assert result.root_options == {"verbose": True}
+ assert result.namespace_options == {"profile": "prod"}
+ assert result.remaining_argv == ["deploy"]
+ assert result.current_head == "deploy"
+
+
+@pytest.mark.parametrize("help_flag", ["-h", "--help"])
+def test_parse_args_help_flag_sets_help_mode(
+ parser: FalyxParser,
+ help_flag: str,
+) -> None:
+ result = parser.parse_args([help_flag])
+
+ assert result.mode is FalyxMode.HELP
+ assert result.help is True
+ assert result.namespace_options["help"] is True
+ assert result.remaining_argv == []
+
+
+def test_parse_args_tldr_flag_sets_help_mode_after_tldr_registered(
+ parser: FalyxParser,
+) -> None:
+ parser.add_tldr_example(
+ entry_key="D",
+ usage="--region us-east",
+ description="Deploy to us-east",
+ )
+
+ result = parser.parse_args(["--tldr"])
+
+ assert result.mode is FalyxMode.HELP
+ assert result.tldr is True
+ assert result.namespace_options["tldr"] is True
+
+
+def test_parse_args_unknown_leading_option_raises(parser: FalyxParser) -> None:
+ with pytest.raises(FalyxOptionError, match="unknown option '--wat'"):
+ parser.parse_args(["--wat"])
+
+
+def test_parse_args_stops_at_first_non_option_boundary(parser: FalyxParser) -> None:
+ result = parser.parse_args(["deploy", "--verbose", "--never-prompt"])
+
+ assert result.root_options == {}
+ assert result.namespace_options == {}
+ assert result.remaining_argv == ["deploy", "--verbose", "--never-prompt"]
+ assert result.current_head == "deploy"
+
+
+def test_parse_args_allows_unknown_options_after_route_boundary(
+ parser: FalyxParser,
+) -> None:
+ result = parser.parse_args(["deploy", "--command-local-option"])
+
+ assert result.remaining_argv == ["deploy", "--command-local-option"]
+
+
+def test_parse_args_store_true_and_store_false(parser: FalyxParser) -> None:
+ parser.add_option("--json", action="store_true")
+ parser.add_option("--color", action="store_false")
+
+ result = parser.parse_args(["--json", "--color"])
+
+ assert result.namespace_defaults["json"] is False
+ assert result.namespace_defaults["color"] is True
+ assert result.namespace_options["json"] is True
+ assert result.namespace_options["color"] is False
+
+
+def test_parse_args_count_option(parser: FalyxParser) -> None:
+ parser.add_option("-q", "--quiet", action="count")
+
+ result = parser.parse_args(["-q", "-q", "--quiet"])
+
+ assert result.namespace_defaults["quiet"] == 0
+ assert result.namespace_options["quiet"] == 3
+
+
+def test_parse_args_posix_bundles_boolean_and_count_options(
+ parser: FalyxParser,
+) -> None:
+ parser.add_option("-q", "--quiet", action="count")
+
+ result = parser.parse_args(["-vdnq", "deploy"])
+
+ assert result.root_options == {
+ "verbose": True,
+ "debug_hooks": True,
+ "never_prompt": True,
+ }
+ assert result.namespace_options == {"quiet": 1}
+ assert result.remaining_argv == ["deploy"]
+
+
+def test_parse_args_posix_bundle_can_end_with_store_option(
+ parser: FalyxParser,
+) -> None:
+ parser.add_option("-q", "--quiet", action="count")
+ parser.add_option("-r", "--region")
+
+ result = parser.parse_args(["-qr", "us-east", "deploy"])
+
+ assert result.namespace_options == {
+ "quiet": 1,
+ "region": "us-east",
+ }
+ assert result.remaining_argv == ["deploy"]
+
+
+def test_parse_args_does_not_expand_invalid_posix_bundle(parser: FalyxParser) -> None:
+ with pytest.raises(FalyxOptionError, match="unknown option '-vz'"):
+ parser.parse_args(["-vz"])
+
+
+def test_parse_args_store_option_requires_value(parser: FalyxParser) -> None:
+ parser.add_option("--region")
+
+ with pytest.raises(FalyxOptionError, match="expected a value"):
+ parser.parse_args(["--region"])
+
+
+def test_parse_args_store_option_coerces_value(parser: FalyxParser) -> None:
+ parser.add_option("--retries", type=int)
+
+ result = parser.parse_args(["--retries", "3"])
+
+ assert result.namespace_options["retries"] == 3
+
+
+def test_parse_args_store_option_rejects_invalid_value(parser: FalyxParser) -> None:
+ parser.add_option("--retries", type=int)
+
+ with pytest.raises(FalyxOptionError, match="invalid value for '--retries'"):
+ parser.parse_args(["--retries", "abc"])
+
+
+def test_parse_args_store_option_rejects_value_outside_choices(
+ parser: FalyxParser,
+) -> None:
+ parser.add_option("--region", choices=["us-east", "us-west"])
+
+ with pytest.raises(FalyxOptionError, match="expected one of"):
+ parser.parse_args(["--region", "eu-central"])
+
+
+def test_suggest_next_returns_no_suggestions_for_empty_args(
+ parser: FalyxParser,
+) -> None:
+ suggestions, expecting_value = parser.suggest_next([], cursor_at_end_of_token=False)
+
+ assert suggestions == []
+ assert expecting_value is False
+
+
+def test_suggest_next_suggests_matching_option_flags(parser: FalyxParser) -> None:
+ parser.add_option("--region", "-r")
+
+ suggestions, expecting_value = parser.suggest_next(
+ ["--r"],
+ cursor_at_end_of_token=False,
+ )
+
+ assert suggestions == ["--region"]
+ assert expecting_value is False
+
+
+def test_suggest_next_suggests_all_remaining_flags_at_token_boundary(
+ parser: FalyxParser,
+) -> None:
+ parser.add_option("--region", "-r")
+
+ suggestions, expecting_value = parser.suggest_next(
+ ["--"],
+ cursor_at_end_of_token=False,
+ )
+
+ assert "--help" in suggestions
+ assert "--verbose" in suggestions
+ assert "--debug-hooks" in suggestions
+ assert "--never-prompt" in suggestions
+ assert "--region" in suggestions
+ assert expecting_value is False
+
+
+def test_suggest_next_suggests_choice_values_after_store_option(
+ parser: FalyxParser,
+) -> None:
+ parser.add_option("--region", choices=["us-east", "us-west"])
+
+ suggestions, expecting_value = parser.suggest_next(
+ ["--region"],
+ cursor_at_end_of_token=True,
+ )
+
+ assert suggestions == ["us-east", "us-west"]
+ assert expecting_value is True
+
+
+def test_suggest_next_filters_choice_values_by_prefix(parser: FalyxParser) -> None:
+ parser.add_option("--region", choices=["us-east", "us-west", "eu-central"])
+
+ suggestions, expecting_value = parser.suggest_next(
+ ["--region", "us-e"],
+ cursor_at_end_of_token=False,
+ )
+
+ assert suggestions == ["us-east"]
+ assert expecting_value is True
+
+
+def test_suggest_next_uses_custom_value_suggestions(parser: FalyxParser) -> None:
+ parser.add_option("--profile", suggestions=["dev", "prod", "staging"])
+
+ suggestions, expecting_value = parser.suggest_next(
+ ["--profile", "pr"],
+ cursor_at_end_of_token=False,
+ )
+
+ assert suggestions == ["prod"]
+ assert expecting_value is True
+
+
+def test_suggest_next_excludes_consumed_options(parser: FalyxParser) -> None:
+ parser.add_option("--region", choices=["us-east", "us-west"])
+
+ parser.parse_args(["--region", "us-east"])
+ suggestions, expecting_value = parser.suggest_next(
+ ["-"],
+ cursor_at_end_of_token=False,
+ )
+
+ assert "--region" not in suggestions
+ assert "-r" not in suggestions
+ assert expecting_value is False
+
+
+def test_add_tldr_example_registers_example_and_tldr_option(
+ parser: FalyxParser,
+) -> None:
+ parser.add_tldr_example(
+ entry_key="D",
+ usage="--region us-east",
+ description="Deploy to us-east",
+ )
+
+ assert parser.tldr_option is not None
+ assert "--tldr" in parser._options_by_dest
+ assert parser._tldr_examples == [
+ FalyxTLDRExample(
+ entry_key="D",
+ usage="--region us-east",
+ description="Deploy to us-east",
+ )
+ ]
+
+
+def test_add_tldr_example_rejects_unknown_entry(parser: FalyxParser) -> None:
+ with pytest.raises(EntryNotFoundError) as error:
+ parser.add_tldr_example(
+ entry_key="depoy",
+ usage="",
+ description="Typo example",
+ )
+
+ assert error.value.unknown_name == "depoy"
+ assert error.value.suggestions == ["DEPLOY"]
+
+
+def test_add_tldr_examples_accepts_dataclass_instances(
+ parser: FalyxParser,
+) -> None:
+ example = FalyxTLDRExample(
+ entry_key="deploy",
+ usage="--region us-east",
+ description="Deploy to us-east",
+ )
+
+ parser.add_tldr_examples([example])
+
+ assert parser.tldr_option is not None
+ assert parser._tldr_examples == [example]
+
+
+def test_add_tldr_examples_accepts_three_tuple_examples(
+ parser: FalyxParser,
+) -> None:
+ parser.add_tldr_examples(
+ [
+ ("deploy", "--region us-east", "Deploy to us-east"),
+ ]
+ )
+
+ assert parser.tldr_option is not None
+ assert parser._tldr_examples == [
+ FalyxTLDRExample(
+ entry_key="deploy",
+ usage="--region us-east",
+ description="Deploy to us-east",
+ )
+ ]
+
+
+def test_add_tldr_examples_rejects_invalid_tuple_shape(
+ parser: FalyxParser,
+) -> None:
+ with pytest.raises(FalyxOptionError, match="invalid TLDR example format"):
+ parser.add_tldr_examples([("deploy", "missing description")])
+
+
+def test_add_tldr_examples_rejects_unknown_entry(parser: FalyxParser) -> None:
+ with pytest.raises(EntryNotFoundError) as error:
+ parser.add_tldr_examples(
+ [
+ ("depoy", "--region us-east", "Typo example"),
+ ]
+ )
+
+ with pytest.raises(EntryNotFoundError) as error:
+ parser.add_tldr_examples(
+ [
+ FalyxTLDRExample(
+ entry_key="depoy",
+ usage="--region us-east",
+ description="Typo example",
+ )
+ ]
+ )
+
+ assert error.value.unknown_name == "depoy"
+ assert error.value.suggestions == ["DEPLOY"]
+
+
+def test_store_bool_optional_registers_positive_and_negative_flags(
+ parser: FalyxParser,
+) -> None:
+ parser.add_option("--cache", action="store_bool_optional")
+
+ assert "--cache" in parser._options_by_dest
+ assert "--no-cache" in parser._options_by_dest
+
+ result = parser.parse_args([])
+ assert result.namespace_defaults["cache"] is None
+
+ result = parser.parse_args(["--cache"])
+ assert result.namespace_options["cache"] is True
+
+ result = parser.parse_args(["--no-cache"])
+ assert result.namespace_options["cache"] is False
+
+
+@pytest.mark.parametrize(
+ ("flag", "expected"),
+ [
+ ("--cache", True),
+ ("--no-cache", False),
+ ],
+)
+def test_parse_args_store_bool_optional_intended_behavior(
+ parser: FalyxParser,
+ flag: str,
+ expected: bool,
+) -> None:
+ parser.add_option("--cache", action="store_bool_optional")
+
+ result = parser.parse_args([flag])
+
+ assert result.namespace_options["cache"] is expected
+
+
+def test_parse_args_store_bool_optional_rejects_multiple_flags(
+ parser: FalyxParser,
+) -> None:
+ with pytest.raises(
+ FalyxOptionError, match="store_bool_optional action can only have a single flag"
+ ):
+ parser.add_option("--cache", "-c", action="store_bool_optional")
+
+
+def test_parse_args_store_bool_optional_rejects_short_flags(parser: FalyxParser) -> None:
+ with pytest.raises(
+ FalyxOptionError, match="store_bool_optional action must use a long flag"
+ ):
+ parser.add_option("-c", action="store_bool_optional")
+
+
+def test_parse_args_long_root_flags(parser: FalyxParser) -> None:
+ result = parser.parse_args(["--verbose", "--debug-hooks", "--never-prompt", "deploy"])
+
+ assert result.root_options == {
+ "verbose": True,
+ "debug_hooks": True,
+ "never_prompt": True,
+ }
+ assert result.namespace_options == {}
+ assert result.remaining_argv == ["deploy"]
+
+
+@pytest.mark.parametrize("help_flag", ["-h", "--help"])
+def test_parse_args_forwards_help_after_route_boundary(
+ parser: FalyxParser,
+ help_flag: str,
+) -> None:
+ result = parser.parse_args(["deploy", help_flag])
+
+ assert result.mode is FalyxMode.COMMAND
+ assert result.help is False
+ assert result.namespace_options == {}
+ assert result.remaining_argv == ["deploy", help_flag]
+
+
+def test_parse_args_short_tldr_flag_sets_help_mode_after_tldr_registered(
+ parser: FalyxParser,
+) -> None:
+ parser.add_tldr_example(
+ entry_key="D",
+ usage="--region us-east",
+ description="Deploy to us-east",
+ )
+
+ result = parser.parse_args(["-T"])
+
+ assert result.mode is FalyxMode.HELP
+ assert result.tldr is True
+ assert result.namespace_options["tldr"] is True
+
+
+def test_add_tldr_examples_registers_tldr_option_only_once(
+ parser: FalyxParser,
+) -> None:
+ parser.add_tldr_example(
+ entry_key="deploy",
+ usage="--region us-east",
+ description="Deploy to us-east",
+ )
+ parser.add_tldr_example(
+ entry_key="deploy",
+ usage="--region us-west",
+ description="Deploy to us-west",
+ )
+
+ tldr_options = [option for option in parser.get_options() if option.dest == "tldr"]
+
+ assert len(tldr_options) == 1
+ assert parser.get_flags().count("--tldr") == 1
+ assert len(parser._tldr_examples) == 2
+
+
+def test_parse_args_resets_consumed_option_state_between_parses(
+ parser: FalyxParser,
+) -> None:
+ parser.add_option("--region", "-r", choices=["us-east", "us-west"])
+
+ parser.parse_args(["--region", "us-east"])
+ suggestions, _ = parser.suggest_next(["-"], cursor_at_end_of_token=False)
+ assert "--region" not in suggestions
+
+ parser.parse_args([])
+ suggestions, expecting_value = parser.suggest_next(
+ ["--r"],
+ cursor_at_end_of_token=False,
+ )
+
+ assert suggestions == ["--region"]
+ assert expecting_value is False
+
+
+def test_disabled_reserved_root_options_are_omitted_from_defaults() -> None:
+ parser = FalyxParser(
+ Falyx(
+ disable_verbose_option=True,
+ disable_debug_hooks_option=True,
+ disable_never_prompt_option=True,
+ )
+ )
+
+ result = parser.parse_args([])
+
+ assert result.root_defaults == {}
+ assert result.namespace_defaults["help"] is False
+
+
+def test_parse_args_typed_choices_are_compared_after_coercion(
+ parser: FalyxParser,
+) -> None:
+ parser.add_option("--retries", type=int, choices=["1", "2"])
+
+ result = parser.parse_args(["--retries", "1"])
+
+ assert result.namespace_options["retries"] == 1
+
+
+def test_add_option_rejects_dict_choices(parser: FalyxParser) -> None:
+ with pytest.raises(FalyxOptionError, match="choices cannot be a dict"):
+ parser.add_option("--region", choices={"east": "us-east"})
+
+
+def test_add_option_rejects_non_iterable_choices(parser: FalyxParser) -> None:
+ with pytest.raises(FalyxOptionError, match="choices must be iterable"):
+ parser.add_option("--region", choices=1)
+
+
+@pytest.mark.parametrize("flag", ["--verbose", "--debug-hooks", "--never-prompt"])
+def test_suggest_next_does_not_expect_value_for_root_boolean_flags(
+ parser: FalyxParser,
+ flag: str,
+) -> None:
+ suggestions, expecting_value = parser.suggest_next(
+ [flag],
+ cursor_at_end_of_token=True,
+ )
+
+ assert suggestions == []
+ assert expecting_value is False
+
+
+def test_add_option_normalizes_typed_choices(parser: FalyxParser) -> None:
+ option = parser.add_option("--retries", type=int, choices=["1", "2"])
+
+ assert option.choices == [1, 2]
+
+
+def test_parse_args_accepts_value_matching_normalized_typed_choice(
+ parser: FalyxParser,
+) -> None:
+ parser.add_option("--retries", type=int, choices=["1", "2"])
+
+ result = parser.parse_args(["--retries", "1"])
+
+ assert result.namespace_options["retries"] == 1
+
+
+def test_add_option_normalizes_typed_default_before_choice_check(
+ parser: FalyxParser,
+) -> None:
+ parser.add_option("--retries", type=int, choices=["1", "2"], default="1")
+
+ result = parser.parse_args([])
+
+ assert result.namespace_defaults["retries"] == 1
+
+
+def test_add_option_returns_registered_option(parser: FalyxParser) -> None:
+ option = parser.add_option(
+ "--retries",
+ type=int,
+ default="1",
+ choices=["1", "2"],
+ )
+
+ assert isinstance(option, Option)
+ assert option.dest == "retries"
+ assert option.default == 1
+ assert option.choices == [1, 2]
+ assert option in parser.get_options()
+
+
+def test_add_option_store_bool_optional_returns_primary_option(
+ parser: FalyxParser,
+) -> None:
+ option = parser.add_option("--cache", action="store_bool_optional")
+
+ assert option.dest == "cache"
+ assert option.flags == ("--cache",)
+ assert "--cache" in parser._options_by_dest
+ assert "--no-cache" in parser._options_by_dest
diff --git a/tests/test_hook_manager.py b/tests/test_hook_manager.py
new file mode 100644
index 0000000..f6d150f
--- /dev/null
+++ b/tests/test_hook_manager.py
@@ -0,0 +1,226 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+from typing import Any
+
+import pytest
+
+from falyx.hook_manager import HookManager, HookType
+
+
+def make_context(*, name: str = "DemoAction", exception: Exception | None = None) -> Any:
+ return SimpleNamespace(name=name, exception=exception, events=[])
+
+
+def test_hook_type_choices_aliases_and_string_representation() -> None:
+ assert HookType.choices() == [
+ HookType.BEFORE,
+ HookType.ON_SUCCESS,
+ HookType.ON_ERROR,
+ HookType.AFTER,
+ HookType.ON_TEARDOWN,
+ ]
+ assert HookType(" before ") is HookType.BEFORE
+ assert HookType("success") is HookType.ON_SUCCESS
+ assert HookType(" ERROR ") is HookType.ON_ERROR
+ assert HookType("teardown") is HookType.ON_TEARDOWN
+ assert str(HookType.AFTER) == "after"
+
+
+@pytest.mark.parametrize("bad_value", [7, object()])
+def test_hook_type_rejects_non_string_missing_values(bad_value: object) -> None:
+ with pytest.raises(ValueError, match="Invalid HookType"):
+ HookType(bad_value)
+
+
+def test_hook_type_rejects_unknown_string_with_valid_choices() -> None:
+ with pytest.raises(ValueError) as exc_info:
+ HookType("not-a-hook")
+
+ message = str(exc_info.value)
+ assert "Invalid HookType: 'not-a-hook'" in message
+ assert "before" in message
+ assert "on_success" in message
+ assert "on_error" in message
+ assert "after" in message
+ assert "on_teardown" in message
+
+
+def test_manager_initializes_all_hook_buckets_and_registers_aliases() -> None:
+ manager = HookManager()
+
+ assert set(manager._hooks) == set(HookType)
+ assert all(hooks == [] for hooks in manager._hooks.values())
+
+ def before_hook(context: Any) -> None:
+ context.events.append("before")
+
+ def success_hook(context: Any) -> None:
+ context.events.append("success")
+
+ manager.register(HookType.BEFORE, before_hook)
+ manager.register("success", success_hook)
+
+ assert manager._hooks[HookType.BEFORE] == [before_hook]
+ assert manager._hooks[HookType.ON_SUCCESS] == [success_hook]
+
+
+def test_register_rejects_invalid_hook_type() -> None:
+ manager = HookManager()
+
+ def hook(context: Any) -> None:
+ context.events.append("never-called")
+
+ with pytest.raises(ValueError, match="Invalid HookType"):
+ manager.register("missing-phase", hook)
+
+
+@pytest.mark.asyncio
+async def test_trigger_runs_sync_and_async_hooks_in_registration_order() -> None:
+ manager = HookManager()
+ context = make_context()
+
+ def sync_first(ctx: Any) -> None:
+ ctx.events.append("sync-first")
+
+ async def async_second(ctx: Any) -> None:
+ ctx.events.append("async-second")
+
+ def sync_third(ctx: Any) -> None:
+ ctx.events.append("sync-third")
+
+ manager.register("before", sync_first)
+ manager.register(HookType.BEFORE, async_second)
+ manager.register("before", sync_third)
+
+ await manager.trigger(HookType.BEFORE, context)
+
+ assert context.events == ["sync-first", "async-second", "sync-third"]
+
+
+@pytest.mark.asyncio
+async def test_trigger_rejects_unsupported_runtime_hook_type() -> None:
+ manager = HookManager()
+
+ with pytest.raises(ValueError, match="Unsupported hook type"):
+ await manager.trigger("not-a-hook", make_context()) # type: ignore[arg-type]
+
+
+@pytest.mark.asyncio
+async def test_trigger_logs_and_continues_after_non_error_hook_failure() -> None:
+ manager = HookManager()
+ context = make_context()
+
+ def failing_hook(ctx: Any) -> None:
+ ctx.events.append("failing")
+ raise RuntimeError("hook exploded")
+
+ def surviving_hook(ctx: Any) -> None:
+ ctx.events.append("surviving")
+
+ manager.register(HookType.BEFORE, failing_hook)
+ manager.register(HookType.BEFORE, surviving_hook)
+
+ await manager.trigger(HookType.BEFORE, context)
+
+ assert context.events == ["failing", "surviving"]
+
+
+@pytest.mark.asyncio
+async def test_trigger_on_error_hook_failure_reraises_original_context_exception() -> (
+ None
+):
+ manager = HookManager()
+ original_error = ValueError("original failure")
+ context = make_context(exception=original_error)
+
+ def failing_error_hook(ctx: Any) -> None:
+ ctx.events.append("error-hook")
+ raise RuntimeError("error hook failed")
+
+ manager.register("error", failing_error_hook)
+
+ with pytest.raises(ValueError) as exc_info:
+ await manager.trigger(HookType.ON_ERROR, context)
+
+ assert exc_info.value is original_error
+ assert isinstance(exc_info.value.__cause__, RuntimeError)
+ assert str(exc_info.value.__cause__) == "error hook failed"
+ assert context.events == ["error-hook"]
+
+
+@pytest.mark.asyncio
+async def test_trigger_on_error_requires_context_exception_when_hook_fails() -> None:
+ manager = HookManager()
+ context = make_context(exception=None)
+
+ def failing_error_hook(ctx: Any) -> None:
+ raise RuntimeError("error hook failed")
+
+ manager.register(HookType.ON_ERROR, failing_error_hook)
+
+ with pytest.raises(AssertionError, match="Context exception should be set"):
+ await manager.trigger(HookType.ON_ERROR, context)
+
+
+def test_clear_removes_one_hook_bucket_or_all_buckets() -> None:
+ manager = HookManager()
+
+ def before_hook(context: Any) -> None:
+ context.events.append("before")
+
+ def after_hook(context: Any) -> None:
+ context.events.append("after")
+
+ manager.register("before", before_hook)
+ manager.register("after", after_hook)
+
+ manager.clear(HookType.BEFORE)
+
+ assert manager._hooks[HookType.BEFORE] == []
+ assert manager._hooks[HookType.AFTER] == [after_hook]
+
+ manager.clear()
+
+ assert all(hooks == [] for hooks in manager._hooks.values())
+
+
+def test_string_representation_lists_registered_hook_names_and_empty_buckets() -> None:
+ manager = HookManager()
+
+ def before_hook(context: Any) -> None:
+ context.events.append("before")
+
+ manager.register("before", before_hook)
+
+ text = str(manager)
+
+ assert text.startswith("")
+ assert "before: before_hook" in text
+ assert "on_success: —" in text
+ assert "on_error: —" in text
+ assert "after: —" in text
+ assert "on_teardown: —" in text
+
+
+def test_copy_copies_hook_lists_without_sharing_list_objects() -> None:
+ manager = HookManager()
+
+ def first_hook(context: Any) -> None:
+ context.events.append("first")
+
+ def second_hook(context: Any) -> None:
+ context.events.append("second")
+
+ manager.register("teardown", first_hook)
+
+ clone = manager.copy()
+
+ assert clone is not manager
+ assert clone._hooks[HookType.ON_TEARDOWN] == [first_hook]
+ assert clone._hooks[HookType.ON_TEARDOWN] is not manager._hooks[HookType.ON_TEARDOWN]
+
+ clone.register("teardown", second_hook)
+
+ assert manager._hooks[HookType.ON_TEARDOWN] == [first_hook]
+ assert clone._hooks[HookType.ON_TEARDOWN] == [first_hook, second_hook]
diff --git a/tests/test_main.py b/tests/test_main.py
index 58add25..6d9b2eb 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -1,19 +1,11 @@
import shutil
import sys
import tempfile
-from argparse import ArgumentParser, Namespace, _SubParsersAction
from pathlib import Path
import pytest
-from falyx.__main__ import (
- bootstrap,
- find_falyx_config,
- get_parsers,
- init_callback,
- init_config,
- main,
-)
+from falyx.__main__ import bootstrap, find_falyx_config, init_config, main
from falyx.parser import CommandArgumentParser
@@ -94,38 +86,10 @@ async def test_init_config():
assert args["name"] == "."
-def test_init_callback(tmp_path):
- """Test if the init_callback function works correctly."""
- # Test project initialization
- args = Namespace(command="init", name=str(tmp_path))
- init_callback(args)
- assert (tmp_path / "falyx.yaml").exists()
-
-
-def test_init_global_callback():
- # Test global initialization
- args = Namespace(command="init_global")
- init_callback(args)
- assert (Path.home() / ".config" / "falyx" / "tasks.py").exists()
- assert (Path.home() / ".config" / "falyx" / "falyx.yaml").exists()
-
-
-def test_get_parsers():
- """Test if the get_parsers function returns the correct parsers."""
- root_parser, subparsers = get_parsers()
- assert isinstance(root_parser, ArgumentParser)
- assert isinstance(subparsers, _SubParsersAction)
-
- # Check if the 'init' command is available
- init_parser = subparsers.choices.get("init")
- assert init_parser is not None
- assert "name" == init_parser._get_positional_actions()[0].dest
-
-
def test_main():
"""Test if the main function runs with the correct arguments."""
- sys.argv = ["falyx", "run", "?"]
+ sys.argv = ["falyx", "?"]
with pytest.raises(SystemExit) as exc_info:
main()
diff --git a/tests/test_parsers/test_action.py b/tests/test_parsers/test_action.py
index 6598b5c..d3064a2 100644
--- a/tests/test_parsers/test_action.py
+++ b/tests/test_parsers/test_action.py
@@ -71,22 +71,28 @@ async def test_action_with_nargs_positional():
return int(a) * int(b)
action = Action("multiply", multiply)
- parser.add_argument("mul", action=ArgumentAction.ACTION, resolver=action, nargs=2)
+ parser.add_argument(
+ "mul",
+ action=ArgumentAction.ACTION,
+ resolver=action,
+ nargs=2,
+ type=int,
+ )
args = await parser.parse_args(["3", "4"])
assert args["mul"] == 12
with pytest.raises(CommandArgumentError):
await parser.parse_args(["3"])
- with pytest.raises(CommandArgumentError):
- await parser.parse_args([])
-
with pytest.raises(CommandArgumentError):
await parser.parse_args(["3", "4", "5"])
with pytest.raises(CommandArgumentError):
await parser.parse_args(["--mul", "3", "4"])
+ with pytest.raises(CommandArgumentError):
+ await parser.parse_args([])
+
@pytest.mark.asyncio
async def test_action_with_nargs_positional_int():
@@ -102,6 +108,9 @@ async def test_action_with_nargs_positional_int():
args = await parser.parse_args(["3", "4"])
assert args["mul"] == 12
+ with pytest.raises(CommandArgumentError):
+ await parser.parse_args([])
+
with pytest.raises(CommandArgumentError):
await parser.parse_args(["3"])
@@ -209,11 +218,19 @@ async def test_action_with_default_and_value_not():
@pytest.mark.asyncio
async def test_action_with_default_and_value_positional():
parser = CommandArgumentParser()
- action = Action("default", lambda: "default_value")
- parser.add_argument("default", action=ArgumentAction.ACTION, resolver=action)
+ action = Action("action", lambda x: x)
+ parser.add_argument(
+ "default",
+ action=ArgumentAction.ACTION,
+ resolver=action,
+ default="default_value",
+ )
+
+ args = await parser.parse_args([])
+ assert args["default"] == "default_value"
+
+ args = await parser.parse_args(["be"])
+ assert args["default"] == "be"
with pytest.raises(CommandArgumentError):
- await parser.parse_args([])
-
- with pytest.raises(CommandArgumentError):
- await parser.parse_args(["be"])
+ await parser.parse_args(["one", "new_value"])
diff --git a/tests/test_command_argument_parser.py b/tests/test_parsers/test_command_argument_parser.py
similarity index 78%
rename from tests/test_command_argument_parser.py
rename to tests/test_parsers/test_command_argument_parser.py
index 9882aa7..6c0e901 100644
--- a/tests/test_command_argument_parser.py
+++ b/tests/test_parsers/test_command_argument_parser.py
@@ -1,7 +1,11 @@
import pytest
+from rich.text import Text
-from falyx.exceptions import CommandArgumentError
-from falyx.parser import ArgumentAction, CommandArgumentParser
+from falyx.action import Action
+from falyx.console import console as falyx_console
+from falyx.exceptions import CommandArgumentError, NotAFalyxError
+from falyx.options_manager import OptionsManager
+from falyx.parser import Argument, ArgumentAction, CommandArgumentParser
from falyx.signals import HelpSignal
@@ -431,7 +435,6 @@ async def test_parse_args_flagged_nargs_plus():
assert args["files"] == ["a", "b", "c"]
args = await parser.parse_args(["--files", "a"])
- print(args)
assert args["files"] == ["a"]
args = await parser.parse_args([])
@@ -666,7 +669,7 @@ async def test_parse_args_split_order():
cap.add_argument("a")
cap.add_argument("--x")
cap.add_argument("b", nargs="*")
- args, kwargs = await cap.parse_args_split(["1", "--x", "100", "2"])
+ args, kwargs, _ = await cap.parse_args_split(["1", "--x", "100", "2"])
assert args == ("1", ["2"])
assert kwargs == {"x": "100"}
@@ -826,4 +829,200 @@ async def test_render_help():
parser.add_argument("--foo", type=str, help="Foo help")
parser.add_argument("--bar", action=ArgumentAction.APPEND, type=str, help="Bar help")
- assert parser.render_help() is None
+ with falyx_console.capture() as capture:
+ parser.render_help()
+ output = Text.from_ansi(capture.get()).plain
+ assert "usage:" in output
+ assert "--foo" in output
+ assert "Foo help" in output
+ assert "--bar" in output
+ assert "Bar help" in output
+
+
+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)
+
+
+def test_add_argument_returns_registered_argument() -> None:
+ parser = CommandArgumentParser()
+
+ arg = parser.add_argument(
+ "--retries",
+ type=int,
+ default="1",
+ choices=["1", "2"],
+ )
+
+ assert isinstance(arg, Argument)
+ assert arg.dest == "retries"
+ assert arg.default == 1
+ assert arg.choices == [1, 2]
+ assert parser.get_argument("retries") is arg
diff --git a/tests/test_parsers/test_command_argument_parser_clone_contract.py b/tests/test_parsers/test_command_argument_parser_clone_contract.py
new file mode 100644
index 0000000..8d4695f
--- /dev/null
+++ b/tests/test_parsers/test_command_argument_parser_clone_contract.py
@@ -0,0 +1,373 @@
+from falyx.console import console
+from falyx.execution_option import ExecutionOption
+from falyx.options_manager import OptionsManager
+from falyx.parser import CommandArgumentParser
+from falyx.parser.parser_types import TLDRExample
+
+
+def build_parser() -> CommandArgumentParser:
+ parser = CommandArgumentParser(
+ command_key="D",
+ command_description="Deploy",
+ help_text="Deploy something.",
+ help_epilog="More help text.",
+ aliases=["deploy"],
+ program="source",
+ options_manager=OptionsManager(),
+ )
+
+ parser.add_argument("--region", choices=["us-east", "us-west"], default="us-east")
+ parser.add_argument("target")
+
+ group = parser.add_argument_group("auth", description="Authentication options")
+ group.add_argument("--profile", suggestions=["dev", "prod"])
+
+ mutex = parser.add_mutually_exclusive_group(
+ "mode",
+ required=False,
+ description="Execution mode",
+ )
+ mutex.add_argument("--dry-run", action="store_true")
+ mutex.add_argument("--apply", action="store_true")
+
+ parser.add_tldr_examples(
+ [
+ ("target-1 --region us-east", "Deploy target-1 to us-east."),
+ ("target-2 --dry-run", "Preview target-2 without executing."),
+ ]
+ )
+
+ parser.enable_execution_options(
+ frozenset(
+ {
+ ExecutionOption.SUMMARY,
+ ExecutionOption.RETRY,
+ ExecutionOption.CONFIRM,
+ }
+ )
+ )
+ return parser
+
+
+def build_parser_with_tldr_examples() -> CommandArgumentParser:
+ parser = build_parser()
+ parser.add_tldr_examples(
+ [
+ ("target-3 --profile dev", "Deploy target-3 using dev profile."),
+ ]
+ )
+ return parser
+
+
+def build_parser_with_groups() -> CommandArgumentParser:
+ parser = build_parser()
+ group = parser.add_argument_group("output", description="Output options")
+ group.add_argument("--json", action="store_true")
+ return parser
+
+
+def build_parser_with_execution_options() -> CommandArgumentParser:
+ parser = build_parser()
+ parser.enable_execution_options(
+ frozenset(
+ {
+ ExecutionOption.SUMMARY,
+ ExecutionOption.RETRY,
+ ExecutionOption.CONFIRM,
+ }
+ )
+ )
+ return parser
+
+
+def test_clone_with_overrides_preserves_core_metadata():
+ original = build_parser()
+ new_options = OptionsManager()
+
+ cloned = original.clone_with_overrides(
+ command_key="X",
+ command_description="Execute",
+ help_text="Execute something else.",
+ help_epilog="Different epilog.",
+ aliases=["execute"],
+ program="target",
+ options_manager=new_options,
+ )
+
+ assert cloned is not original
+ assert cloned.command_key == "X"
+ assert cloned.command_description == "Execute"
+ assert cloned.help_text == "Execute something else."
+ assert cloned.help_epilog == "Different epilog."
+ assert cloned.aliases == ["execute"]
+ assert cloned.program == "target"
+ assert cloned.options_manager is new_options
+
+
+def test_clone_with_overrides_keeps_execution_options_enabled_without_double_registration():
+ original = build_parser()
+ cloned = original.clone_with_overrides()
+
+ summary = cloned.get_argument("summary")
+ retries = cloned.get_argument("retries")
+ retry_delay = cloned.get_argument("retry_delay")
+ retry_backoff = cloned.get_argument("retry_backoff")
+ force_confirm = cloned.get_argument("force_confirm")
+ skip_confirm = cloned.get_argument("skip_confirm")
+
+ assert summary is not None
+ assert retries is not None
+ assert retry_delay is not None
+ assert retry_backoff is not None
+ assert force_confirm is not None
+ assert skip_confirm is not None
+
+ # Re-enabling on the clone should be idempotent, not duplicate flags/dests.
+ cloned.enable_execution_options(
+ frozenset(
+ {
+ ExecutionOption.SUMMARY,
+ ExecutionOption.RETRY,
+ ExecutionOption.CONFIRM,
+ }
+ )
+ )
+
+ assert len([arg for arg in cloned._arguments if arg.dest == "summary"]) == 1
+ assert len([arg for arg in cloned._arguments if arg.dest == "retries"]) == 1
+ assert len([arg for arg in cloned._arguments if arg.dest == "retry_delay"]) == 1
+ assert len([arg for arg in cloned._arguments if arg.dest == "retry_backoff"]) == 1
+ assert len([arg for arg in cloned._arguments if arg.dest == "force_confirm"]) == 1
+ assert len([arg for arg in cloned._arguments if arg.dest == "skip_confirm"]) == 1
+
+
+def test_clone_with_overrides_preserves_groups_and_mutex_groups():
+ original = build_parser()
+ cloned = original.clone_with_overrides()
+
+ assert "auth" in cloned._argument_groups
+ assert "mode" in cloned._mutex_groups
+
+ assert cloned._arg_group_by_dest["profile"] == "auth"
+ assert cloned._mutex_group_by_dest["dry_run"] == "mode"
+ assert cloned._mutex_group_by_dest["apply"] == "mode"
+
+ assert cloned.get_argument("profile") is not None
+ assert cloned.get_argument("dry_run") is not None
+ assert cloned.get_argument("apply") is not None
+
+
+def test_clone_with_overrides_preserves_tldr_examples_and_help_flags():
+ original = build_parser()
+ cloned = original.clone_with_overrides()
+
+ assert cloned.help_text == original.help_text
+ assert cloned.help_epilog == original.help_epilog
+ assert cloned.get_argument("help") is not None
+ assert cloned.get_argument("tldr") is not None
+ assert cloned._tldr_examples == original._tldr_examples
+ assert cloned._tldr_examples is not original._tldr_examples
+
+
+def test_clone_with_overrides_does_not_share_argument_registries_with_original():
+ original = build_parser()
+ cloned = original.clone_with_overrides()
+
+ assert cloned._arguments is not original._arguments
+ assert cloned._positional is not original._positional
+ assert cloned._keyword is not original._keyword
+ assert cloned._keyword_list is not original._keyword_list
+ assert cloned._flag_map is not original._flag_map
+ assert cloned._dest_set is not original._dest_set
+ assert cloned._execution_dests is not original._execution_dests
+
+ cloned.add_argument("--new-flag", default="x")
+
+ assert cloned.get_argument("new_flag") is not None
+ assert original.get_argument("new_flag") is None
+
+
+def test_clone_with_overrides_does_not_share_group_registries_with_original():
+ original = build_parser()
+ cloned = original.clone_with_overrides()
+
+ assert cloned._argument_groups is not original._argument_groups
+ assert cloned._mutex_groups is not original._mutex_groups
+ assert cloned._arg_group_by_dest is not original._arg_group_by_dest
+ assert cloned._mutex_group_by_dest is not original._mutex_group_by_dest
+
+ cloned_group = cloned.add_argument_group("output", description="Output options")
+ cloned_group.add_argument("--json", action="store_true")
+
+ assert "output" in cloned._argument_groups
+ assert "output" not in original._argument_groups
+ assert cloned.get_argument("json") is not None
+ assert original.get_argument("json") is None
+
+
+def test_clone_with_overrides_reuses_no_mutable_group_objects():
+ original = build_parser()
+ cloned = original.clone_with_overrides()
+
+ # These should ideally be distinct objects too, not just distinct dicts.
+ assert cloned._argument_groups["auth"] is not original._argument_groups["auth"]
+ assert cloned._mutex_groups["mode"] is not original._mutex_groups["mode"]
+
+
+def test_clone_with_overrides_reuses_no_mutable_argument_objects():
+ original = build_parser()
+ cloned = original.clone_with_overrides()
+
+ # Strict contract: cloned parser should not share Argument instances either.
+ assert cloned.get_argument("region") is not original.get_argument("region")
+ assert cloned.get_argument("target") is not original.get_argument("target")
+ assert cloned.get_argument("profile") is not original.get_argument("profile")
+
+
+def test_clone_with_overrides_uses_new_options_manager():
+ original = build_parser()
+ new_options = OptionsManager()
+
+ cloned = original.clone_with_overrides(options_manager=new_options)
+
+ assert cloned.options_manager is new_options
+ assert original.options_manager is not new_options
+
+
+def test_clone_with_overrides_has_single_help_and_single_tldr_argument():
+ parser = build_parser_with_tldr_examples()
+
+ cloned = parser.clone_with_overrides()
+
+ assert len([arg for arg in cloned._arguments if arg.dest == "help"]) == 1
+ assert len([arg for arg in cloned._arguments if arg.dest == "tldr"]) == 1
+ assert cloned.get_argument("help") is not None
+ assert cloned.get_argument("tldr") is not None
+
+
+def test_clone_with_overrides_copies_tldr_examples():
+ parser = build_parser_with_tldr_examples()
+
+ cloned = parser.clone_with_overrides()
+
+ assert cloned._tldr_examples == parser._tldr_examples
+ assert cloned._tldr_examples is not parser._tldr_examples
+ assert all(c is not o for c, o in zip(cloned._tldr_examples, parser._tldr_examples))
+
+
+def test_clone_with_overrides_copies_explicit_tldr_examples():
+ parser = build_parser()
+ examples = [TLDRExample("foo", "bar")]
+
+ cloned = parser.clone_with_overrides(tldr_examples=examples)
+
+ assert cloned._tldr_examples == examples
+ assert cloned._tldr_examples is not examples
+
+
+def test_clone_with_overrides_does_not_share_aliases_list():
+ parser = build_parser()
+ cloned = parser.clone_with_overrides()
+
+ assert cloned.aliases == parser.aliases
+ assert cloned.aliases is not parser.aliases
+
+ cloned.aliases.append("new-alias")
+ assert "new-alias" not in parser.aliases
+
+
+def test_clone_with_overrides_rebuilds_group_membership_without_duplicates():
+ parser = build_parser_with_groups()
+ cloned = parser.clone_with_overrides()
+
+ assert cloned._argument_groups["auth"].dests == {"profile"}
+ assert set(cloned._mutex_groups["mode"].dests) == {"dry_run", "apply"}
+ assert len(cloned._mutex_groups["mode"].dests) == 2
+
+
+def test_clone_with_overrides_does_not_share_group_objects():
+ parser = build_parser_with_groups()
+ cloned = parser.clone_with_overrides()
+
+ assert cloned._argument_groups is not parser._argument_groups
+ assert cloned._mutex_groups is not parser._mutex_groups
+ assert cloned._argument_groups["auth"] is not parser._argument_groups["auth"]
+ assert cloned._mutex_groups["mode"] is not parser._mutex_groups["mode"]
+
+
+def test_clone_with_overrides_does_not_share_argument_objects():
+ parser = build_parser()
+ cloned = parser.clone_with_overrides()
+
+ for original_arg in parser._arguments:
+ cloned_arg = cloned.get_argument(original_arg.dest)
+ console.print(original_arg)
+ console.print(cloned_arg)
+ assert cloned_arg is not None
+ assert cloned_arg is not original_arg
+ assert cloned_arg == original_arg
+
+
+def test_clone_with_overrides_internal_registries_point_to_cloned_arguments():
+ parser = build_parser()
+ cloned = parser.clone_with_overrides()
+
+ for arg in cloned._arguments:
+ for flag in arg.flags:
+ assert cloned._flag_map[flag] is arg
+ if not arg.positional:
+ assert cloned._keyword[flag] is arg
+
+ if arg.positional:
+ assert cloned._positional[arg.dest] is arg
+ else:
+ assert arg in cloned._keyword_list
+
+
+def test_clone_with_overrides_preserves_execution_option_state_without_duplication():
+ parser = build_parser_with_execution_options()
+ cloned = parser.clone_with_overrides()
+
+ assert cloned._summary_enabled is True
+ assert cloned._retries_enabled is True
+ assert cloned._confirm_enabled is True
+ assert cloned._execution_dests == parser._execution_dests
+ assert cloned._execution_dests is not parser._execution_dests
+
+ cloned.enable_execution_options(
+ frozenset(
+ {
+ ExecutionOption.SUMMARY,
+ ExecutionOption.RETRY,
+ ExecutionOption.CONFIRM,
+ }
+ )
+ )
+
+ assert len([arg for arg in cloned._arguments if arg.dest == "summary"]) == 1
+ assert len([arg for arg in cloned._arguments if arg.dest == "retries"]) == 1
+ assert len([arg for arg in cloned._arguments if arg.dest == "retry_delay"]) == 1
+ assert len([arg for arg in cloned._arguments if arg.dest == "retry_backoff"]) == 1
+ assert len([arg for arg in cloned._arguments if arg.dest == "force_confirm"]) == 1
+ assert len([arg for arg in cloned._arguments if arg.dest == "skip_confirm"]) == 1
+
+
+def test_clone_with_overrides_preserves_runner_and_help_mode_flags():
+ parser = build_parser()
+ parser.is_runner_mode = True
+ parser.mark_as_help_command()
+
+ cloned = parser.clone_with_overrides()
+
+ assert cloned.is_runner_mode is True
+ assert cloned._is_help_command is True
+
+
+def test_clone_with_overrides_mutating_clone_does_not_mutate_original():
+ parser = build_parser()
+ cloned = parser.clone_with_overrides()
+
+ cloned.add_argument("--new-flag", default="x")
+
+ assert cloned.get_argument("new_flag") is not None
+ assert parser.get_argument("new_flag") is None
diff --git a/tests/test_parsers/test_command_argument_parser_extra.py b/tests/test_parsers/test_command_argument_parser_extra.py
new file mode 100644
index 0000000..09f3daa
--- /dev/null
+++ b/tests/test_parsers/test_command_argument_parser_extra.py
@@ -0,0 +1,459 @@
+from __future__ import annotations
+
+from io import StringIO
+from pathlib import Path
+
+import pytest
+from rich.console import Console
+
+from falyx.action.action import Action
+from falyx.exceptions import CommandArgumentError, InvalidValueError
+from falyx.execution_option import ExecutionOption
+from falyx.parser.command_argument_parser import CommandArgumentParser
+from falyx.signals import HelpSignal
+
+
+@pytest.fixture
+def parser() -> CommandArgumentParser:
+ return CommandArgumentParser(
+ command_key="D",
+ command_description="Deploy service",
+ help_text="Deploy a service.",
+ help_epilog="Deployment epilog.",
+ aliases=["deploy"],
+ program="flx",
+ )
+
+
+def capture_console(parser: CommandArgumentParser) -> StringIO:
+ stream = StringIO()
+ parser.console = Console(
+ file=stream,
+ force_terminal=False,
+ color_system=None,
+ width=120,
+ )
+ return stream
+
+
+def test_add_argument_rejects_suggestions_with_non_string_members(
+ parser: CommandArgumentParser,
+) -> None:
+ with pytest.raises(
+ CommandArgumentError, match="suggestions must be a list of strings"
+ ):
+ parser.add_argument("--region", suggestions=["dev", 1])
+
+
+@pytest.mark.asyncio
+async def test_parse_accepts_multi_value_choice_list_when_all_values_are_valid(
+ parser: CommandArgumentParser,
+) -> None:
+ parser.add_argument(
+ "--ports",
+ type=int,
+ nargs="+",
+ choices=["80", 443],
+ default=[],
+ )
+
+ result = await parser.parse_args(["--ports", "80", "443"])
+
+ assert result["ports"] == [80, 443]
+
+
+@pytest.mark.asyncio
+async def test_positional_action_wraps_resolver_failure(
+ parser: CommandArgumentParser,
+) -> None:
+ async def fail_resolver(value: str) -> str:
+ raise RuntimeError(f"cannot resolve {value}")
+
+ parser.add_argument(
+ "target",
+ action="action",
+ resolver=Action("Resolve target", fail_resolver),
+ lazy_resolver=False,
+ )
+
+ with pytest.raises(
+ CommandArgumentError, match=r"\[target\] action failed: cannot resolve web"
+ ):
+ await parser.parse_args(["web"])
+
+
+@pytest.mark.asyncio
+async def test_dash_prefixed_numeric_token_can_be_a_positional_value(
+ parser: CommandArgumentParser,
+) -> None:
+ parser.add_argument("delta", type=int)
+
+ result = await parser.parse_args(["-3"])
+
+ assert result["delta"] == -3
+
+
+@pytest.mark.asyncio
+async def test_store_option_without_value_raises_type_specific_prompt(
+ parser: CommandArgumentParser,
+) -> None:
+ parser.add_argument("--count", type=int, help="Number of instances.")
+
+ with pytest.raises(CommandArgumentError, match="enter a int value for 'count'"):
+ await parser.parse_args(["--count"])
+
+
+@pytest.mark.asyncio
+async def test_append_option_without_value_raises_type_specific_prompt(
+ parser: CommandArgumentParser,
+) -> None:
+ parser.add_argument("--tag", action="append", help="Deployment tag.")
+
+ with pytest.raises(CommandArgumentError, match="enter a str value for 'tag'"):
+ await parser.parse_args(["--tag"])
+
+
+@pytest.mark.asyncio
+async def test_tldr_flag_on_help_command_is_parsed_as_a_normal_value(
+ parser: CommandArgumentParser,
+) -> None:
+ parser.mark_as_help_command()
+ parser.add_tldr_example("D --region us-east", "Deploy to us-east")
+
+ result = await parser.parse_args(["--tldr"])
+
+ assert result["tldr"] is True
+
+
+@pytest.mark.asyncio
+async def test_tldr_flag_renders_examples_and_raises_help_signal(
+ parser: CommandArgumentParser,
+) -> None:
+ stream = capture_console(parser)
+ parser.add_tldr_example("--region us-east", "Deploy to us-east")
+
+ with pytest.raises(HelpSignal):
+ await parser.parse_args(["--tldr"])
+
+ output = stream.getvalue()
+ assert "usage:" in output
+ assert "examples:" in output
+ assert "Deploy to us-east" in output
+ assert "--region us-east" in output
+
+
+@pytest.mark.asyncio
+async def test_required_mutex_group_requires_one_member(
+ parser: CommandArgumentParser,
+) -> None:
+ mode = parser.add_mutually_exclusive_group("mode", required=True)
+ mode.add_argument("--dry-run", action="store_true")
+ mode.add_argument("--apply", action="store_true")
+
+ with pytest.raises(
+ CommandArgumentError, match="one of the following is required for group 'mode'"
+ ):
+ await parser.parse_args([])
+
+
+@pytest.mark.asyncio
+async def test_mutex_group_rejects_multiple_present_members(
+ parser: CommandArgumentParser,
+) -> None:
+ mode = parser.add_mutually_exclusive_group("mode")
+ mode.add_argument("--dry-run", action="store_true")
+ mode.add_argument("--apply", action="store_true")
+
+ with pytest.raises(
+ CommandArgumentError,
+ match="cannot be used together: (dry_run, apply|apply, dry_run)",
+ ):
+ await parser.parse_args(["--dry-run", "--apply"])
+
+
+@pytest.mark.parametrize(
+ ("argument_kwargs", "argv"),
+ [
+ ({"flags": ("--enabled",), "action": "store_true"}, ["--enabled", "--other"]),
+ ({"flags": ("--disabled",), "action": "store_false"}, ["--disabled", "--other"]),
+ (
+ {"flags": ("--feature",), "action": "store_bool_optional"},
+ ["--no-feature", "--other"],
+ ),
+ ({"flags": ("-v", "--verbose"), "action": "count"}, ["-v", "--other"]),
+ ({"flags": ("--tag",), "action": "append"}, ["--tag", "beta", "--other"]),
+ (
+ {"flags": ("--item",), "action": "extend", "nargs": "+"},
+ ["--item", "a", "--other"],
+ ),
+ ({"flags": ("--name",)}, ["--name", "web", "--other"]),
+ ],
+)
+@pytest.mark.asyncio
+async def test_mutex_presence_detection_handles_all_supported_action_shapes(
+ argument_kwargs: dict,
+ argv: list[str],
+) -> None:
+ parser = CommandArgumentParser(command_key="D")
+ only_one = parser.add_mutually_exclusive_group("only-one")
+ kwargs = argument_kwargs.copy()
+ flags = kwargs.pop("flags")
+ only_one.add_argument(*flags, **kwargs)
+ only_one.add_argument("--other", action="store_true")
+
+ with pytest.raises(CommandArgumentError, match="cannot be used together"):
+ await parser.parse_args(argv)
+
+
+@pytest.mark.asyncio
+async def test_parse_args_split_separates_execution_options_from_command_inputs(
+ parser: CommandArgumentParser,
+) -> None:
+ parser.add_argument("service")
+ parser.add_argument("--region", default="us-east")
+ parser.enable_execution_options(
+ frozenset(
+ {
+ ExecutionOption.SUMMARY,
+ ExecutionOption.RETRY,
+ ExecutionOption.CONFIRM,
+ }
+ )
+ )
+
+ args, kwargs, execution_args = await parser.parse_args_split(
+ [
+ "api",
+ "--region",
+ "us-west",
+ "--summary",
+ "--retries",
+ "3",
+ "--retry-delay",
+ "0.5",
+ "--retry-backoff",
+ "2.0",
+ "--skip-confirm",
+ ]
+ )
+
+ assert args == ("api",)
+ assert kwargs == {"region": "us-west"}
+ assert execution_args == {
+ "summary": True,
+ "retries": 3,
+ "retry_delay": 0.5,
+ "retry_backoff": 2.0,
+ "force_confirm": False,
+ "skip_confirm": True,
+ }
+
+
+@pytest.mark.asyncio
+async def test_lazy_action_required_argument_is_deferred_during_validation(
+ parser: CommandArgumentParser,
+) -> None:
+ calls: list[str] = []
+
+ async def resolve(value: str) -> str:
+ calls.append(value)
+ return value.upper()
+
+ parser.add_argument(
+ "--target",
+ action="action",
+ resolver=Action("Resolve target", resolve),
+ required=True,
+ )
+
+ result = await parser.parse_args(["--target", "web"], from_validate=True)
+
+ assert result["target"] is None
+ assert calls == []
+
+
+@pytest.mark.asyncio
+async def test_lazy_action_required_argument_still_errors_when_no_tokens_are_present(
+ parser: CommandArgumentParser,
+) -> None:
+ async def resolve(value: str) -> str:
+ return value.upper()
+
+ parser.add_argument(
+ "--target",
+ action="action",
+ resolver=Action("Resolve target", resolve),
+ required=True,
+ )
+
+ with pytest.raises(CommandArgumentError, match="missing required argument 'target'"):
+ await parser.parse_args([], from_validate=True)
+
+
+@pytest.mark.asyncio
+async def test_default_list_with_wrong_fixed_nargs_arity_is_invalid(
+ parser: CommandArgumentParser,
+) -> None:
+ parser.add_argument("--pair", nargs=2, default=["only-one"])
+
+ with pytest.raises(InvalidValueError) as exc_info:
+ await parser.parse_args([])
+
+ assert exc_info.value.dest == "pair"
+ assert "expected 2" in str(exc_info.value)
+
+
+@pytest.mark.asyncio
+async def test_required_plus_nargs_option_requires_at_least_one_value(
+ parser: CommandArgumentParser,
+) -> None:
+ parser.add_argument("--item", nargs="+", required=True)
+
+ with pytest.raises(
+ CommandArgumentError, match="argument 'item' requires at least one value"
+ ):
+ await parser.parse_args([])
+
+
+@pytest.mark.asyncio
+async def test_suggest_next_filters_mutex_siblings_after_one_member_is_consumed(
+ parser: CommandArgumentParser,
+) -> None:
+ mode = parser.add_mutually_exclusive_group("mode")
+ mode.add_argument("--dry-run", action="store_true")
+ mode.add_argument("--apply", action="store_true")
+ parser.add_argument("--region", choices=["us-east", "us-west"])
+
+ await parser.parse_args(["--dry-run"])
+
+ suggestions = parser.suggest_next(["--dry-run"], cursor_at_end_of_token=True)
+
+ assert "--apply" not in suggestions
+ assert "--region" in suggestions
+
+
+@pytest.mark.asyncio
+async def test_optional_choice_argument_can_be_omitted(
+ parser: CommandArgumentParser,
+) -> None:
+ parser.add_argument("--dry-run", action="store_true")
+ parser.add_argument("--region", choices=["us-east", "us-west"])
+
+ result = await parser.parse_args(["--dry-run"])
+
+ assert result["dry_run"] is True
+ assert result["region"] is None
+
+
+@pytest.mark.asyncio
+async def test_suggest_next_returns_no_values_after_invalid_choice_is_committed(
+ parser: CommandArgumentParser,
+) -> None:
+ parser.add_argument("--region", choices=["dev", "prod"])
+
+ with pytest.raises(InvalidValueError):
+ await parser.parse_args(["--region", "qa"])
+
+ assert parser.suggest_next(["--region", "qa"], cursor_at_end_of_token=True) == []
+
+
+@pytest.mark.asyncio
+async def test_suggest_next_suggests_value_for_keyword_when_stub_starts_with_dash(
+ parser: CommandArgumentParser,
+) -> None:
+ parser.add_argument("--profile", suggestions=["-prod", "-stage", "dev"])
+
+ with pytest.raises(CommandArgumentError):
+ await parser.parse_args(["--profile"], from_validate=True)
+
+ assert parser.suggest_next(["--profile", "-"], cursor_at_end_of_token=False) == [
+ "-prod",
+ "-stage",
+ ]
+
+
+@pytest.mark.asyncio
+async def test_suggest_next_returns_empty_for_missing_path_base(
+ parser: CommandArgumentParser,
+ tmp_path: Path,
+) -> None:
+ missing = tmp_path / "missing-dir" / "config.toml"
+ parser.add_argument("--config", type=Path)
+
+ await parser.parse_args(["--config", str(missing)], from_validate=True)
+
+ assert (
+ parser.suggest_next(["--config", str(missing)], cursor_at_end_of_token=False)
+ == []
+ )
+
+
+def test_get_options_text_repeats_fixed_width_positional_nargs(
+ parser: CommandArgumentParser,
+) -> None:
+ parser.add_argument("coords", nargs=2)
+
+ assert "coords coords" in parser.get_options_text()
+
+
+def test_get_usage_uses_program_only_when_parser_is_in_runner_mode(
+ parser: CommandArgumentParser,
+) -> None:
+ parser.is_runner_mode = True
+ parser.program = "deploy-tool"
+
+ usage = parser.get_usage()
+
+ assert "deploy-tool" in usage
+ assert "[bold]D[/bold]" not in usage
+ assert "[bold]deploy[/bold]" not in usage
+
+
+def test_render_help_includes_grouped_keywords_bool_optional_pair_and_epilog(
+ parser: CommandArgumentParser,
+) -> None:
+ stream = capture_console(parser)
+ parser.add_argument("environment", help="Target environment.")
+ deploy = parser.add_argument_group("deploy", "Deployment options.")
+ deploy.add_argument("--region", help="Target region.")
+ mode = parser.add_mutually_exclusive_group("mode")
+ mode.add_argument("--dry-run", action="store_true", help="Preview only.")
+ parser.add_argument("--cache", action="store_bool_optional", help="Use cache.")
+
+ parser.render_help()
+
+ output = stream.getvalue()
+ assert "usage:" in output
+ assert "Deploy a service." in output
+ assert "positional:" in output
+ assert "environment" in output
+ assert "deploy:" in output
+ assert "Deployment options." in output
+ assert "--cache, --no-cache" in output
+ assert "Deployment epilog." in output
+
+
+def test_render_tldr_without_examples_prints_empty_state_message(
+ parser: CommandArgumentParser,
+) -> None:
+ stream = capture_console(parser)
+
+ parser.render_tldr()
+
+ assert "No TLDR examples available for D" in stream.getvalue()
+
+
+def test_render_tldr_with_examples_prints_usage_help_and_example_panel(
+ parser: CommandArgumentParser,
+) -> None:
+ stream = capture_console(parser)
+ parser.add_tldr_example("--region us-east", "Deploy east")
+
+ parser.render_tldr()
+
+ output = stream.getvalue()
+ assert "usage:" in output
+ assert "Deploy a service." in output
+ assert "examples:" in output
+ assert "Deploy east" in output
+ assert "--region us-east" in output
diff --git a/tests/test_parsers/test_execution_option_registration.py b/tests/test_parsers/test_execution_option_registration.py
new file mode 100644
index 0000000..f6a3367
--- /dev/null
+++ b/tests/test_parsers/test_execution_option_registration.py
@@ -0,0 +1,155 @@
+import pytest
+
+from falyx.exceptions import CommandArgumentError
+from falyx.execution_option import ExecutionOption
+from falyx.parser import CommandArgumentParser
+
+
+def test_enable_execution_options_registers_summary_flag():
+ parser = CommandArgumentParser()
+ parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
+ assert "--summary" in parser._flag_map
+ assert "--summary" in parser._keyword
+ assert "--summary" in parser._flag_map
+ assert "summary" in parser._execution_dests
+
+
+def test_enable_execution_options_registers_retry_flags():
+ parser = CommandArgumentParser()
+ parser.enable_execution_options(frozenset({ExecutionOption.RETRY}))
+ assert "--retries" in parser._flag_map
+ assert "--retries" in parser._keyword
+ assert "--retries" in parser._flag_map
+ assert "retries" in parser._execution_dests
+ assert "--retry-delay" in parser._flag_map
+ assert "--retry-delay" in parser._keyword
+ assert "--retry-delay" in parser._flag_map
+ assert "retry_delay" in parser._execution_dests
+ assert "--retry-backoff" in parser._flag_map
+ assert "--retry-backoff" in parser._keyword
+ assert "--retry-backoff" in parser._flag_map
+ assert "retry_backoff" in parser._execution_dests
+
+
+def test_enable_execution_options_invalid_double_registration_raises():
+ parser = CommandArgumentParser()
+ 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}))
+ assert "--confirm" in parser._flag_map
+ assert "--confirm" in parser._keyword
+ assert "--confirm" in parser._flag_map
+ assert "force_confirm" in parser._execution_dests
+ assert "--skip-confirm" in parser._flag_map
+ assert "--skip-confirm" in parser._keyword
+ assert "--skip-confirm" in parser._flag_map
+ assert "skip_confirm" in parser._execution_dests
+
+
+def test_register_execution_dest_rejects_duplicates():
+ parser = CommandArgumentParser()
+ parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
+ with pytest.raises(
+ CommandArgumentError, match="destination 'summary' is already defined"
+ ):
+ parser.add_argument("--summary", action="store_true")
+
+ with pytest.raises(
+ CommandArgumentError,
+ match="destination 'summary' is already registered as an execution argument",
+ ):
+ parser._register_execution_dest("summary")
+
+
+@pytest.mark.asyncio
+async def test_parse_args_split_with_execution_options_returns_correct_execution_args():
+ parser = CommandArgumentParser()
+ parser.add_argument("foo", type=int, help="A business argument.")
+ parser.add_argument("--bar", type=int, help="A business argument.")
+ parser.enable_execution_options(
+ frozenset({ExecutionOption.SUMMARY, ExecutionOption.RETRY})
+ )
+
+ args, kwargs, execution_args = await parser.parse_args_split(
+ ["50", "--bar", "42", "--summary", "--retries", "3"]
+ )
+
+ assert args == (50,)
+ assert kwargs == {"bar": 42}
+ assert execution_args == {
+ "summary": True,
+ "retries": 3,
+ "retry_delay": 0.0,
+ "retry_backoff": 0.0,
+ }
+
+
+@pytest.mark.asyncio
+async def test_parse_args_split_with_all_execution_options_returns_correct_execution_args():
+ parser = CommandArgumentParser()
+ parser.add_argument("foo", type=int, help="A business argument.")
+ parser.add_argument("--bar", type=int, help="A business argument.")
+ parser.enable_execution_options(
+ frozenset(
+ {
+ ExecutionOption.SUMMARY,
+ ExecutionOption.RETRY,
+ ExecutionOption.CONFIRM,
+ }
+ )
+ )
+
+ args, kwargs, execution_args = await parser.parse_args_split(
+ [
+ "50",
+ "--bar",
+ "42",
+ "--summary",
+ "--retries",
+ "3",
+ "--confirm",
+ ]
+ )
+
+ assert args == (50,)
+ assert kwargs == {"bar": 42}
+ assert execution_args == {
+ "summary": True,
+ "retries": 3,
+ "retry_delay": 0.0,
+ "retry_backoff": 0.0,
+ "force_confirm": True,
+ "skip_confirm": False,
+ }
+
+
+@pytest.mark.asyncio
+async def test_parse_args_split_with_no_execution_options_returns_empty_execution_args():
+ parser = CommandArgumentParser()
+ parser.add_argument("foo", type=int, help="A business argument.")
+ parser.add_argument("--bar", type=int, help="A business argument.")
+
+ args, kwargs, execution_args = await parser.parse_args_split(["50", "--bar", "42"])
+
+ assert args == (50,)
+ assert kwargs == {"bar": 42}
+ assert execution_args == {}
+
+
+@pytest.mark.asyncio
+async def test_parse_args_split_with_conflicting_execution_option_raises():
+ parser = CommandArgumentParser()
+ parser.add_argument("--summary", action="store_true", help="A conflicting argument.")
+ with pytest.raises(
+ CommandArgumentError, match="destination 'summary' is already defined"
+ ):
+ parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
diff --git a/tests/test_parsers/test_group_builder.py b/tests/test_parsers/test_group_builder.py
new file mode 100644
index 0000000..8101ce8
--- /dev/null
+++ b/tests/test_parsers/test_group_builder.py
@@ -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,
+ )
diff --git a/tests/test_parsers/test_resolve_args.py b/tests/test_parsers/test_resolve_args.py
new file mode 100644
index 0000000..528c1f2
--- /dev/null
+++ b/tests/test_parsers/test_resolve_args.py
@@ -0,0 +1,242 @@
+import pytest
+
+from falyx.command import Command
+from falyx.exceptions import CommandArgumentError, NotAFalyxError
+from falyx.execution_option import ExecutionOption
+
+
+@pytest.mark.asyncio
+async def test_resolve_args_separates_business_and_execution_options():
+ command = Command.build(
+ key="T",
+ description="Test Command",
+ action=lambda: None,
+ execution_options=["summary", "retry"],
+ )
+ command.arg_parser.add_argument("--foo", type=int, help="A business argument.")
+
+ args, kwargs, execution_args = await command.resolve_args(
+ ["--foo", "42", "--summary", "--retries", "3"]
+ )
+
+ assert args == ()
+ assert kwargs == {"foo": 42}
+ assert execution_args == {
+ "summary": True,
+ "retries": 3,
+ "retry_delay": 0.0,
+ "retry_backoff": 0.0,
+ }
+
+ args, kwargs, execution_args = await command.arg_parser.parse_args_split(
+ ["--foo", "42", "--summary", "--retries", "3"]
+ )
+
+ assert args == ()
+ assert kwargs == {"foo": 42}
+ assert execution_args == {
+ "summary": True,
+ "retries": 3,
+ "retry_delay": 0.0,
+ "retry_backoff": 0.0,
+ }
+
+
+@pytest.mark.asyncio
+async def test_parse_args_split_with_no_execution_options_returns_empty_execution_args():
+ command = Command.build(
+ key="T",
+ description="Test Command",
+ action=lambda: None,
+ )
+ command.arg_parser.add_argument("--foo", type=int, help="A business argument.")
+
+ args, kwargs, execution_args = await command.arg_parser.parse_args_split(
+ ["--foo", "42"]
+ )
+
+ assert args == ()
+ assert kwargs == {"foo": 42}
+ assert execution_args == {}
+
+
+@pytest.mark.asyncio
+async def test_resolve_args_raises_on_conflicting_execution_option():
+ command = Command.build(
+ key="T",
+ description="Test Command",
+ action=lambda: None,
+ execution_options=["summary"],
+ )
+ with pytest.raises(
+ CommandArgumentError, match="destination 'summary' is already defined"
+ ):
+ command.arg_parser.add_argument(
+ "--summary", action="store_true", help="A conflicting argument."
+ )
+
+ with pytest.raises(
+ CommandArgumentError,
+ match="destination 'summary' is already registered as an execution argument",
+ ):
+ command.arg_parser._register_execution_dest("summary")
+
+
+@pytest.mark.asyncio
+async def test_resolve_args_mix_of_business_and_execution_options():
+ command = Command.build(
+ key="T",
+ description="Test Command",
+ action=lambda: None,
+ execution_options=["retry"],
+ )
+ command.arg_parser.add_argument("--summary", type=str, help="A business argument.")
+
+ args, kwargs, execution_args = await command.resolve_args(
+ ["--summary", "test", "--retries", "5", "--retry-delay", "2"]
+ )
+
+ assert args == ()
+ assert kwargs == {"summary": "test"}
+ assert execution_args == {"retries": 5, "retry_delay": 2.0, "retry_backoff": 0.0}
+
+
+@pytest.mark.asyncio
+async def test_resolve_args_with_no_arguments():
+ command = Command.build(
+ key="T",
+ description="Test Command",
+ action=lambda: None,
+ execution_options=["summary"],
+ )
+
+ args, kwargs, execution_args = await command.resolve_args([])
+
+ assert args == ()
+ assert kwargs == {}
+ assert execution_args == {"summary": False}
+
+
+@pytest.mark.asyncio
+async def test_resolve_args_with_confirmation_options():
+ command = Command.build(
+ key="T",
+ description="Test Command",
+ action=lambda: None,
+ execution_options=["confirm"],
+ )
+
+ args, kwargs, execution_args = await command.resolve_args(["--confirm"])
+
+ assert args == ()
+ assert kwargs == {}
+ assert execution_args == {"force_confirm": True, "skip_confirm": False}
+
+ args, kwargs, execution_args = await command.resolve_args(["--skip-confirm"])
+
+ assert args == ()
+ assert kwargs == {}
+ assert execution_args == {"force_confirm": False, "skip_confirm": True}
+
+
+@pytest.mark.asyncio
+async def test_resolve_args_with_all_execution_options():
+ command = Command.build(
+ key="T",
+ description="Test Command",
+ action=lambda: None,
+ execution_options=["summary", "retry", "confirm"],
+ )
+
+ args, kwargs, execution_args = await command.resolve_args(
+ ["--summary", "--retries", "3", "--confirm"]
+ )
+
+ assert args == ()
+ assert kwargs == {}
+ assert execution_args == {
+ "summary": True,
+ "retries": 3,
+ "retry_delay": 0.0,
+ "retry_backoff": 0.0,
+ "force_confirm": True,
+ "skip_confirm": False,
+ }
+
+
+@pytest.mark.asyncio
+async def test_resolve_args_with_raw_string_input():
+ command = Command.build(
+ key="T",
+ description="Test Command",
+ action=lambda: None,
+ execution_options=["summary"],
+ )
+ command.arg_parser.add_argument("--foo", type=int, help="A business argument.")
+
+ args, kwargs, execution_args = await command.resolve_args("--foo 42 --summary")
+
+ assert args == ()
+ assert kwargs == {"foo": 42}
+ assert execution_args == {"summary": True}
+
+
+@pytest.mark.asyncio
+async def test_resolve_args_with_no_arg_parser():
+ command = Command.build(
+ key="T",
+ description="Test Command",
+ action=lambda: None,
+ execution_options=["summary"],
+ )
+ command.arg_parser = None
+
+ with pytest.raises(
+ NotAFalyxError,
+ match="Command has no parser configured. Provide a custom_parser or CommandArgumentParser.",
+ ):
+ await command.resolve_args("--summary")
+
+
+@pytest.mark.asyncio
+async def test_resolve_args_with_custom_parser():
+ def parse_args_split(arg_list):
+ return (arg_list,), {}, {"custom_execution_arg": True}
+
+ command = Command.build(
+ key="T",
+ description="Test Command",
+ action=lambda: None,
+ execution_options=["summary"],
+ )
+ command.custom_parser = parse_args_split
+
+ args, kwargs, execution_args = await command.resolve_args("--summary")
+
+ assert args == (["--summary"],)
+ assert kwargs == {}
+ assert execution_args == {"custom_execution_arg": True}
+
+ # TODO: is this the right behavior? Should we expect the custom parser to handle non string inputs as well? Does this actually happen?
+ args, kwargs, execution_args = await command.resolve_args(2235235)
+
+ assert args == (2235235,)
+ assert kwargs == {}
+ assert execution_args == {"custom_execution_arg": True}
+
+ with pytest.raises(CommandArgumentError, match="Failed to parse arguments:"):
+ args, kwargs, execution_args = await command.resolve_args("unbalanced 'quotes")
+
+
+@pytest.mark.asyncio
+async def test_resolve_args_str_unbalanced_quotes():
+ command = Command.build(
+ key="T",
+ description="Test Command",
+ action=lambda: None,
+ execution_options=["summary"],
+ )
+ command.arg_parser.add_argument("--foo", type=str, help="A business argument.")
+
+ with pytest.raises(CommandArgumentError, match="Failed to parse arguments:"):
+ await command.resolve_args("--foo 'unbalanced quotes")
diff --git a/tests/test_parsers/test_tldr.py b/tests/test_parsers/test_tldr.py
index 89023d4..0255428 100644
--- a/tests/test_parsers/test_tldr.py
+++ b/tests/test_parsers/test_tldr.py
@@ -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
diff --git a/tests/test_run_key.py b/tests/test_run_key.py
deleted file mode 100644
index 79f6a1f..0000000
--- a/tests/test_run_key.py
+++ /dev/null
@@ -1,46 +0,0 @@
-import pytest
-
-from falyx import Falyx
-from falyx.action import Action
-
-
-@pytest.mark.asyncio
-async def test_run_key():
- """Test if Falyx can run in run key mode."""
- falyx = Falyx("Run Key Test")
-
- # Add a simple command
- falyx.add_command(
- key="T",
- description="Test Command",
- action=lambda: "Hello, World!",
- )
-
- # Run the CLI
- result = await falyx.run_key("T")
- assert result == "Hello, World!"
-
-
-@pytest.mark.asyncio
-async def test_run_key_recover():
- """Test if Falyx can recover from a failure in run key mode."""
- falyx = Falyx("Run Key Recovery Test")
-
- state = {"count": 0}
-
- async def flaky():
- if not state["count"]:
- state["count"] += 1
- raise RuntimeError("Random failure!")
- return "ok"
-
- # Add a command that raises an exception
- falyx.add_command(
- key="E",
- description="Error Command",
- action=Action("flaky", flaky),
- retry=True,
- )
-
- result = await falyx.run_key("E")
- assert result == "ok"
diff --git a/tests/test_runner/test_command_runner.py b/tests/test_runner/test_command_runner.py
new file mode 100644
index 0000000..3d8199f
--- /dev/null
+++ b/tests/test_runner/test_command_runner.py
@@ -0,0 +1,668 @@
+import asyncio
+import logging
+import sys
+
+import pytest
+from rich.console import Console
+from rich.text import Text
+
+from falyx import Falyx
+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.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.parser import CommandArgumentParser
+from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
+
+
+async def ok_action(*args, **kwargs):
+ falyx_console.print("Action executed with args:", args, "and kwargs:", kwargs)
+ return "ok"
+
+
+async def failing_action(*args, **kwargs):
+ raise RuntimeError("boom")
+
+
+async def throw_error_action(error: str):
+ if error == "QuitSignal":
+ raise QuitSignal("Quit signal triggered.")
+ elif error == "BackSignal":
+ raise BackSignal("Back signal triggered.")
+ elif error == "CancelSignal":
+ raise CancelSignal("Cancel signal triggered.")
+ elif error == "ValueError":
+ raise ValueError("This is a ValueError.")
+ elif error == "HelpSignal":
+ raise HelpSignal("Help signal triggered.")
+ elif error == "FalyxError":
+ raise FalyxError("This is a FalyxError.")
+ else:
+ raise asyncio.CancelledError("An error occurred in the action.")
+
+
+@pytest.fixture
+def command_throwing_error():
+ command = Command(
+ key="E",
+ description="Error Command",
+ action=Action("throw_error", throw_error_action),
+ execution_options=["retry"],
+ )
+ return command
+
+
+@pytest.fixture
+def command_with_parser():
+ command = Command(
+ key="T",
+ description="Test Command",
+ action=ok_action,
+ )
+ command.arg_parser.add_argument("--foo", type=int, help="A business argument.")
+ return command
+
+
+@pytest.fixture
+def command_with_no_parser():
+ command = Command(
+ key="T",
+ description="Test Command",
+ action=ok_action,
+ execution_options=["summary"],
+ )
+ command.arg_parser = None
+ return command
+
+
+@pytest.fixture
+def command_with_custom_parser():
+ def parse_args_split(arg_list):
+ return (arg_list,), {}, {"custom_execution_arg": True}
+
+ command = Command(
+ key="T",
+ description="Test Command",
+ action=ok_action,
+ execution_options=["summary"],
+ )
+ command.custom_parser = parse_args_split
+ return command
+
+
+@pytest.fixture
+def command_with_failing_action():
+ command = Command(
+ key="T",
+ description="Test Command",
+ action=failing_action,
+ execution_options=["summary", "retry"],
+ )
+ command.arg_parser.add_argument("--foo", type=int, help="A business argument.")
+ return command
+
+
+@pytest.fixture
+def command_build_with_all_execution_options():
+ return Command.build(
+ key="T",
+ description="Test Command",
+ action=ok_action,
+ execution_options=["summary", "retry", "confirm"],
+ )
+
+
+@pytest.fixture
+def console():
+ return Console(record=True)
+
+
+@pytest.mark.asyncio
+async def test_command_runner_initialization(
+ command_with_parser,
+ command_with_no_parser,
+ command_with_custom_parser,
+):
+ runner = CommandRunner(command_with_parser, 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_manager, OptionsManager)
+ assert isinstance(runner.runner_hooks, HookManager)
+ assert runner.console == falyx_console
+ assert runner.command.options_manager == runner.options_manager
+ assert runner.command.arg_parser.options_manager == runner.options_manager
+ assert runner.command.options_manager == runner.options_manager
+ assert runner.executor.options_manager == runner.options_manager
+ assert runner.executor.hooks == runner.runner_hooks
+ assert runner.options_manager.get("summary", namespace_name="execution") is None
+
+ runner_no_parser = CommandRunner(command_with_no_parser)
+ assert runner_no_parser.command == command_with_no_parser
+ assert runner_no_parser.command.arg_parser is None
+
+ CommandRunner(command_with_no_parser)
+ with pytest.raises(
+ NotAFalyxError,
+ match="Command has no parser configured. Provide a custom_parser or CommandArgumentParser.",
+ ):
+ await runner_no_parser.run("--summary")
+
+ runner_custom_parser = CommandRunner(command_with_custom_parser)
+ assert runner_custom_parser.command == command_with_custom_parser
+ assert runner_custom_parser.command.custom_parser is not None
+
+
+def test_command_runner_initialization_with_custom_options(command_with_parser):
+ custom_options = OptionsManager([("default", {"summary": True})])
+ runner = CommandRunner(command_with_parser, options_manager=custom_options)
+ assert runner.options_manager == custom_options
+ assert runner.options_manager.get("summary", namespace_name="default") is True
+ assert runner.command.options_manager == runner.options_manager
+ assert runner.command.arg_parser.options_manager == runner.options_manager
+ assert runner.command.options_manager == runner.options_manager
+
+
+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
+
+
+def test_command_runner_initialization_with_custom_hooks(command_with_parser):
+ custom_hooks = HookManager()
+ custom_hooks.register("before", lambda context: print("Before hook"))
+ runner = CommandRunner(command_with_parser, runner_hooks=custom_hooks)
+ assert runner.runner_hooks == custom_hooks
+ assert runner.executor.hooks == custom_hooks
+ assert runner.runner_hooks._hooks[HookType.BEFORE]
+
+
+def test_command_runner_initialization_with_all_bad_components(command_with_parser):
+ custom_options = "Not an OptionsManager"
+ custom_console = 23456
+ custom_hooks = "Not a HookManager"
+
+ with pytest.raises(
+ NotAFalyxError, match="options_manager must be an instance of OptionsManager"
+ ):
+ CommandRunner(
+ command_with_parser,
+ options_manager=custom_options,
+ )
+
+ with pytest.raises(
+ NotAFalyxError, match="console must be an instance of rich.Console"
+ ):
+ CommandRunner(
+ command_with_parser,
+ console=custom_console,
+ )
+
+ with pytest.raises(
+ InvalidHookError, match="hooks must be an instance of HookManager"
+ ):
+ CommandRunner(
+ command_with_parser,
+ runner_hooks=custom_hooks,
+ )
+
+
+@pytest.mark.asyncio
+async def test_command_runner_run(command_with_parser):
+ runner = CommandRunner(command_with_parser)
+ with falyx_console.capture() as capture:
+ result = await runner.run("--foo 42")
+ captured = Text.from_ansi(capture.get()).plain
+ assert result == "ok"
+ assert "Action executed with args:" in captured
+ assert "and kwargs:" in captured
+ assert "{'foo': 42}" in captured
+
+ falyx_console.clear()
+ with falyx_console.capture() as capture:
+ result = await runner.run(["--foo", "123"])
+ captured = Text.from_ansi(capture.get()).plain
+ assert result == "ok"
+ assert "Action executed with args:" in captured
+ assert "and kwargs:" in captured
+ assert "{'foo': 123}" in captured
+
+
+@pytest.mark.asyncio
+async def test_command_runner_run_with_failing_action(command_with_failing_action):
+ runner = CommandRunner(command_with_failing_action)
+ with pytest.raises(RuntimeError, match="boom"):
+ await runner.run("--foo 42")
+
+ with pytest.raises(FalyxError, match="boom"):
+ await runner.run("--foo 42", wrap_errors=True)
+
+
+@pytest.mark.asyncio
+async def test_command_runner_debug_statement(command_with_parser, caplog):
+ logging.getLogger("falyx").setLevel(logging.DEBUG)
+ runner = CommandRunner(command_with_parser)
+ await runner.run("--foo 42")
+ print(command_with_parser.get_option("verbose", namespace_name="root"))
+ assert (
+ "Executing command 'Test Command' with args=(), kwargs={'foo': 42}" in caplog.text
+ )
+
+
+@pytest.mark.asyncio
+async def test_command_runner_run_with_retries_non_action(
+ command_with_failing_action, caplog
+):
+ runner = CommandRunner(command_with_failing_action)
+ with pytest.raises(RuntimeError, match="boom"):
+ await runner.run("--foo 42 --retries 2")
+
+ assert "Retry requested, but action is not an Action instance." in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_command_runner_run_with_retries_with_action(
+ command_throwing_error, caplog
+):
+ runner = CommandRunner(command_throwing_error)
+ with pytest.raises(asyncio.CancelledError, match="An error occurred in the action."):
+ await runner.run("Other")
+
+ with pytest.raises(ValueError, match="This is a ValueError."):
+ await runner.run("ValueError --retries 2")
+
+ assert "[throw_error] Retry attempt 1/2 failed due to 'ValueError'." in caplog.text
+ assert "[throw_error] Retry attempt 2/2 failed due to 'ValueError'." in caplog.text
+ assert "[throw_error] All 2 retries failed." in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_command_runner_run_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,
+):
+ runner = CommandRunner.from_command(command_build_with_all_execution_options)
+ with falyx_console.capture() as capture:
+ result = await runner.run("--summary")
+ captured = Text.from_ansi(capture.get()).plain
+ assert result == "ok"
+ assert "Action executed with args:" in captured
+ assert "and kwargs:" in captured
+ assert "Execution History" in captured
+
+ with falyx_console.capture() as capture:
+ result = await runner.run("--summary", summary_last_result=True)
+ captured = Text.from_ansi(capture.get()).plain
+ assert result == "ok"
+ assert "Action executed with args:" in captured
+ assert "and kwargs:" in captured
+ assert "Command(key='T', description='Test Command' action=" in captured
+ assert "ok" in captured
+
+ with falyx_console.capture() as capture:
+ result = await runner.run("--summary", summary_last_result=False)
+ captured = Text.from_ansi(capture.get()).plain
+ assert result == "ok"
+ assert "Action executed with args:" in captured
+ assert "and kwargs:" in captured
+ assert "Execution History" in captured
+
+
+@pytest.mark.asyncio
+async def test_command_runner_from_command_bad_command():
+ with pytest.raises(NotAFalyxError, match="command must be an instance of Command"):
+ CommandRunner.from_command("Not a Command")
+
+ with pytest.raises(
+ InvalidHookError, match="runner_hooks must be an instance of HookManager"
+ ):
+ CommandRunner.from_command(
+ Command(
+ key="T",
+ description="Test Command",
+ action=ok_action,
+ ),
+ runner_hooks="Not a HookManager",
+ )
+
+
+@pytest.mark.asyncio
+async def test_command_runner_build():
+ runner = CommandRunner.build(
+ key="T",
+ description="Test Command",
+ action=ok_action,
+ execution_options=["summary", "retry"],
+ )
+ assert isinstance(runner, CommandRunner)
+ with falyx_console.capture() as capture:
+ result = await runner.run("--summary --retries 2")
+ captured = Text.from_ansi(capture.get()).plain
+ assert result == "ok"
+ assert "Action executed with args:" in captured
+ assert "and kwargs:" in captured
+ assert "Execution History" in captured
+
+
+@pytest.mark.asyncio
+async def test_command_runner_build_with_bad_execution_options():
+ with pytest.raises(
+ ValueError,
+ match="Invalid ExecutionOption: 'invalid_option'. Must be one of:",
+ ):
+ CommandRunner.build(
+ key="T",
+ description="Test Command",
+ action=ok_action,
+ execution_options=["summary", "invalid_option"],
+ )
+
+
+@pytest.mark.asyncio
+async def test_command_runner_build_with_bad_runner_hooks():
+ with pytest.raises(
+ InvalidHookError, match="runner_hooks must be an instance of HookManager"
+ ):
+ CommandRunner.build(
+ key="T",
+ description="Test Command",
+ action=ok_action,
+ runner_hooks="Not a HookManager",
+ )
+
+
+@pytest.mark.asyncio
+async def test_command_runner_uses_sys_argv(command_with_parser, monkeypatch):
+ runner = CommandRunner(command_with_parser)
+ test_args = ["program_name", "--foo", "42"]
+ monkeypatch.setattr(sys, "argv", test_args)
+ with falyx_console.capture() as capture:
+ result = await runner.run()
+ captured = Text.from_ansi(capture.get()).plain
+ assert result == "ok"
+ assert "Action executed with args:" in captured
+ assert "and kwargs:" in captured
+ assert "{'foo': 42}" in captured
+
+
+@pytest.mark.asyncio
+async def test_command_runner_cli(command_with_parser):
+ runner = CommandRunner(command_with_parser)
+ with falyx_console.capture() as capture:
+ await runner.cli("--foo 42")
+ captured = Text.from_ansi(capture.get()).plain
+ assert "Action executed with args:" in captured
+ assert "and kwargs:" in captured
+ assert "{'foo': 42}" in captured
+
+
+@pytest.mark.asyncio
+async def test_command_runnner_run_propogates_exeptions(command_throwing_error):
+ runner = CommandRunner(command_throwing_error)
+
+ with pytest.raises(QuitSignal, match="Quit signal triggered."):
+ await runner.run("QuitSignal")
+
+ with pytest.raises(BackSignal, match="Back signal triggered."):
+ await runner.run("BackSignal")
+
+ with pytest.raises(CancelSignal, match="Cancel signal triggered."):
+ await runner.run("CancelSignal")
+
+ with pytest.raises(ValueError, match="This is a ValueError."):
+ await runner.run("ValueError")
+
+ with pytest.raises(HelpSignal, match="Help signal triggered."):
+ await runner.run("HelpSignal")
+
+ with pytest.raises(asyncio.CancelledError, match="An error occurred in the action."):
+ await runner.run("Other")
+
+ with pytest.raises(
+ CommandArgumentError,
+ match=r"\[E\] Failed to parse arguments: No closing quotation",
+ ):
+ await runner.run("Mismatched'")
+
+
+@pytest.mark.asyncio
+async def test_command_runner_cli_with_failing_action(command_with_failing_action):
+ runner = CommandRunner(command_with_failing_action)
+ with pytest.raises(SystemExit, match="1"):
+ await runner.cli("--foo 42")
+
+ with pytest.raises(SystemExit, match="2"):
+ await runner.cli("--foo 42 --bar 123")
+
+ with falyx_console.capture() as capture:
+ with pytest.raises(SystemExit, match="0"):
+ await runner.cli(["--help"])
+ captured = Text.from_ansi(capture.get()).plain
+
+ assert "usage: falyx" in captured
+ assert "--foo" in captured
+ assert "summary" in captured
+ assert "retries" in captured
+ assert "A business argument." in captured
+
+
+@pytest.mark.asyncio
+async def test_command_runner_cli_exceptions(command_throwing_error):
+ runner = CommandRunner(command_throwing_error)
+
+ with falyx_console.capture() as capture:
+ with pytest.raises(SystemExit, match="0"):
+ await runner.cli(["--help"])
+ captured = Text.from_ansi(capture.get()).plain
+ assert "falyx [--help]" in captured
+ assert "usage:" in captured
+ assert "positional:" in captured
+ assert "options:" 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 [--help]" in captured
+ assert "usage:" in captured
+ assert "positional:" in captured
+ assert "options:" in captured
+ falyx_console.clear()
+
+ 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
+ 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
+
+ with falyx_console.capture() as capture:
+ with pytest.raises(SystemExit, match="1"):
+ await runner.cli(["BackSignal"])
+ captured = Text.from_ansi(capture.get()).plain
+
+ with falyx_console.capture() as capture:
+ with pytest.raises(SystemExit, match="1"):
+ await runner.cli(["CancelSignal"])
+ captured = Text.from_ansi(capture.get()).plain
+
+ with falyx_console.capture() as capture:
+ with pytest.raises(SystemExit, match="1"):
+ await runner.cli(["Other"])
+ captured = Text.from_ansi(capture.get()).plain
+
+
+@pytest.mark.asyncio
+async def test_command_runner_cli_uses_sys_argv(command_with_parser, monkeypatch):
+ runner = CommandRunner(command_with_parser)
+ test_args = ["program_name", "--foo", "42"]
+ monkeypatch.setattr(sys, "argv", test_args)
+ with falyx_console.capture() as capture:
+ await runner.cli()
+ captured = Text.from_ansi(capture.get()).plain
+ assert "Action executed with args:" in captured
+ assert "and kwargs:" in captured
+ assert "{'foo': 42}" in captured
+
+
+@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)
+
+
+def test_command_runner_from_command_reuses_custom_options_manager_and_seeds_missing_namespaces():
+ flx = Falyx(program="source")
+ original = flx.add_command(
+ key="D",
+ description="Deploy",
+ action=lambda: "ok",
+ )
+
+ custom_options = OptionsManager([("default", {"summary": True})])
+
+ runner = CommandRunner.from_command(
+ original,
+ options_manager=custom_options,
+ )
+
+ assert runner.command is not original
+ assert runner.options_manager is custom_options
+ assert runner.command.options_manager is custom_options
+
+ assert runner.options_manager.get("summary", namespace_name="default") is True
+
+ assert runner.options_manager.get_namespace("root") == {}
+ assert runner.options_manager.get_namespace("execution") == {}
+
+ assert original.options_manager is flx.options_manager
+ assert original.options_manager is not custom_options
+
+
+@pytest.mark.asyncio
+async def test_command_runner_root_options_affect_cloned_command_without_mutating_original(
+ monkeypatch,
+):
+ flx = Falyx(program="source")
+ original = flx.add_command(
+ key="D",
+ description="Deploy",
+ action=Action("deploy-action", lambda: "ok"),
+ confirm=True,
+ )
+
+ original.options_manager.set("never_prompt", False, "root")
+ original.options_manager.set("verbose", False, "root")
+
+ runner_options = OptionsManager()
+ runner_options.from_mapping({}, "root")
+ runner_options.from_mapping({}, "execution")
+ runner_options.set("never_prompt", True, "root")
+ runner_options.set("verbose", True, "root")
+
+ runner = CommandRunner.from_command(
+ original,
+ options_manager=runner_options,
+ )
+
+ calls = {"preview": 0, "confirm": 0}
+
+ async def fake_preview(self):
+ calls["preview"] += 1
+
+ async def fake_confirm(*args, **kwargs):
+ calls["confirm"] += 1
+ return True
+
+ monkeypatch.setattr(Command, "preview", fake_preview)
+ monkeypatch.setattr("falyx.command.confirm_async", fake_confirm)
+
+ result = await runner.run([])
+
+ assert result == "ok"
+
+ assert calls["preview"] == 0
+ assert calls["confirm"] == 0
+
+ assert runner.command.get_option("never_prompt", namespace_name="root") is True
+ assert runner.command.get_option("verbose", namespace_name="root") is True
+
+ assert runner.command.action.get_option("verbose", namespace_name="root") is True
+ assert runner.command.action.never_prompt is True
+
+ assert original.get_option("never_prompt", namespace_name="root") is False
+ assert original.get_option("verbose", namespace_name="root") is False
+ assert original.options_manager is flx.options_manager
+ assert original.options_manager is not runner.options_manager
+
+
+@pytest.mark.asyncio
+async def test_command_runner_from_command_with_custom_options_preserves_parity_and_isolation():
+ falyx = Falyx("Custom Options Parity Test")
+
+ def add(x: int, y: int) -> int:
+ return x + y
+
+ command = falyx.add_command(
+ key="A",
+ description="Add",
+ action=add,
+ )
+
+ custom_options = OptionsManager([("default", {"summary": True})])
+
+ runner = CommandRunner.from_command(
+ command,
+ options_manager=custom_options,
+ )
+
+ falyx_result = await falyx.execute_command("A 2 3")
+ runner_result = await runner.run(["2", "3"])
+
+ assert falyx_result == 5
+ assert runner_result == 5
+ assert falyx_result == runner_result
+
+ assert runner.options_manager is custom_options
+ assert runner.command.options_manager is custom_options
+ assert runner.options_manager.get("summary", namespace_name="default") is True
+ assert runner.options_manager.get_namespace("root") == {}
+ assert runner.options_manager.get_namespace("execution") == {}
+
+ assert runner.command is not command
+ assert command.options_manager is falyx.options_manager
+ assert command.options_manager is not runner.options_manager
diff --git a/tests/test_selection.py b/tests/test_selection.py
new file mode 100644
index 0000000..314cb70
--- /dev/null
+++ b/tests/test_selection.py
@@ -0,0 +1,489 @@
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+from rich import box
+from rich.table import Table
+
+import falyx.selection as selection_module
+from falyx.selection import (
+ SelectionOption,
+ SelectionOptionMap,
+ get_selection_from_dict_menu,
+ prompt_for_index,
+ prompt_for_selection,
+ render_selection_dict_table,
+ render_selection_grid,
+ render_selection_indexed_table,
+ render_table_base,
+ select_key_from_dict,
+ select_value_from_dict,
+ select_value_from_list,
+)
+from falyx.themes import OneColors
+
+
+class CaptureConsole:
+ def __init__(self) -> None:
+ self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
+
+ def print(self, *args: Any, **kwargs: Any) -> None:
+ self.printed.append((args, kwargs))
+
+
+class FakePromptSession:
+ def __init__(self, *responses: str) -> None:
+ self.responses = list(responses)
+ self.calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
+ self.message: Any = "original-message"
+ self.validator: Any = "original-validator"
+ self.placeholder: Any = "original-placeholder"
+
+ async def prompt_async(self, *args: Any, **kwargs: Any) -> str:
+ self.calls.append((args, kwargs))
+ self.message = kwargs.get("message")
+ self.validator = kwargs.get("validator")
+ self.placeholder = kwargs.get("placeholder")
+ if not self.responses:
+ raise AssertionError("No fake prompt response configured")
+ return self.responses.pop(0)
+
+
+@pytest.fixture
+def sample_options() -> dict[str, SelectionOption]:
+ return {
+ "dev": SelectionOption("Development", "dev-value", style="green"),
+ "prod": SelectionOption("Production", "prod-value", style="red"),
+ "stage": SelectionOption("Staging", "stage-value", style="yellow"),
+ }
+
+
+def test_selection_option_rejects_non_string_description() -> None:
+ with pytest.raises(TypeError, match="description must be a string"):
+ SelectionOption(123, "value") # type: ignore[arg-type]
+
+
+def test_selection_option_render_escapes_key_and_applies_style() -> None:
+ option = SelectionOption("Deploy [prod]", "prod", style="red")
+
+ rendered = option.render("a[b]")
+
+ assert "a" in rendered
+ assert "Deploy [prod]" in rendered
+ assert f"[{OneColors.WHITE}]" in rendered
+ assert "[red]" in rendered
+
+
+def test_selection_option_copy_returns_independent_equivalent_option() -> None:
+ option = SelectionOption("Development", {"env": "dev"}, style="green")
+
+ copied = option.copy()
+
+ assert copied == option
+ assert copied is not option
+
+
+def test_selection_option_map_initializes_from_options_case_insensitively(
+ sample_options: dict[str, SelectionOption],
+) -> None:
+ mapping = SelectionOptionMap({"dev": sample_options["dev"]})
+
+ assert mapping["DEV"] is sample_options["dev"]
+ assert mapping["dev"] is sample_options["dev"]
+ assert list(mapping.items()) == [("DEV", sample_options["dev"])]
+
+
+def test_selection_option_map_rejects_non_selection_option_values() -> None:
+ mapping = SelectionOptionMap()
+
+ with pytest.raises(TypeError, match="must be a SelectionOption"):
+ mapping["bad"] = "not an option" # type: ignore[assignment]
+
+ with pytest.raises(TypeError, match="must be a SelectionOption"):
+ mapping.update({"bad": "not an option"})
+
+ with pytest.raises(TypeError, match="must be a SelectionOption"):
+ mapping.update(bad="not an option")
+
+
+def test_selection_option_map_update_accepts_kwargs_and_copy_is_deep_for_options(
+ sample_options: dict[str, SelectionOption],
+) -> None:
+ mapping = SelectionOptionMap()
+ mapping.update(dev=sample_options["dev"])
+ mapping.update({"prod": sample_options["prod"]})
+
+ copied = mapping.copy()
+
+ assert copied.allow_reserved is mapping.allow_reserved
+ assert copied["DEV"] == mapping["dev"]
+ assert copied["DEV"] is not mapping["dev"]
+ assert copied["PROD"].description == "Production"
+
+
+def test_selection_option_map_reserved_key_protection_and_bypass(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(SelectionOptionMap, "RESERVED_KEYS", {"QUIT"})
+ mapping = SelectionOptionMap()
+ reserved_option = SelectionOption("Quit", None)
+
+ with pytest.raises(ValueError, match="reserved"):
+ mapping["quit"] = reserved_option
+
+ mapping._add_reserved("quit", reserved_option)
+ assert mapping["QUIT"] is reserved_option
+
+ with pytest.raises(ValueError, match="Cannot delete reserved option"):
+ del mapping["quit"]
+
+ assert list(mapping.items(include_reserved=False)) == []
+
+
+def test_selection_option_map_allows_reserved_key_when_configured(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(SelectionOptionMap, "RESERVED_KEYS", {"QUIT"})
+ mapping = SelectionOptionMap(allow_reserved=True)
+ option = SelectionOption("Quit", None)
+
+ mapping["quit"] = option
+ assert mapping["QUIT"] is option
+
+ del mapping["quit"]
+ assert "QUIT" not in mapping
+
+
+def test_render_table_base_uses_explicit_column_names_and_styles() -> None:
+ table = render_table_base(
+ "Environments",
+ caption="Choose carefully",
+ column_names=["Name", "Description"],
+ box_style=box.ROUNDED,
+ show_lines=True,
+ show_header=True,
+ show_footer=True,
+ style="blue",
+ header_style="bold",
+ footer_style="dim",
+ title_style="green",
+ caption_style="yellow",
+ highlight=False,
+ )
+
+ assert isinstance(table, Table)
+ assert table.title == "Environments"
+ assert table.caption == "Choose carefully"
+ assert len(table.columns) == 2
+ assert table.columns[0].header == "Name"
+ assert table.columns[1].header == "Description"
+ assert table.show_header is True
+ assert table.show_footer is True
+ assert table.highlight is False
+
+
+def test_render_table_base_creates_blank_columns_when_no_names_are_given() -> None:
+ table = render_table_base("Choices", columns=3)
+
+ assert len(table.columns) == 3
+
+
+def test_render_selection_grid_chunks_rows() -> None:
+ table = render_selection_grid("Choices", ["alpha", "beta", "gamma"], columns=2)
+
+ assert table.title == "Choices"
+ assert len(table.columns) == 2
+ assert len(table.rows) == 2
+
+
+def test_render_selection_indexed_table_uses_default_and_custom_formatters() -> None:
+ default_table = render_selection_indexed_table(
+ "Indexed", ["alpha", "beta", "gamma"], columns=2
+ )
+ formatted_table = render_selection_indexed_table(
+ "Formatted",
+ ["alpha", "beta"],
+ columns=2,
+ formatter=lambda index, value: f"{index}:{value.upper()}",
+ )
+
+ assert len(default_table.rows) == 2
+ assert len(formatted_table.rows) == 1
+
+
+def test_render_selection_dict_table_renders_option_rows(
+ sample_options: dict[str, SelectionOption],
+) -> None:
+ table = render_selection_dict_table(
+ "Environments",
+ sample_options,
+ columns=2,
+ caption="Pick one",
+ highlight=True,
+ )
+
+ assert table.title == "Environments"
+ assert table.caption == "Pick one"
+ assert len(table.columns) == 2
+ assert len(table.rows) == 2
+ assert table.highlight is True
+
+
+@pytest.mark.asyncio
+async def test_prompt_for_index_returns_single_index_and_restores_prompt_state(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ fake_console = CaptureConsole()
+ monkeypatch.setattr(selection_module, "console", fake_console)
+ session = FakePromptSession(" 2 ")
+ table = render_table_base("Choices")
+
+ result = await prompt_for_index(
+ 5,
+ table,
+ default_selection="1",
+ prompt_session=session, # type: ignore[arg-type]
+ prompt_message="[bold]Pick >[/] ",
+ show_table=True,
+ number_selections=1,
+ cancel_key="9",
+ )
+
+ assert result == 2
+ assert len(fake_console.printed) == 1
+ _, kwargs = session.calls[0]
+ assert kwargs["default"] == "1"
+ assert kwargs["placeholder"] == "Enter selection"
+ assert kwargs["validator"].__class__.__name__ == "MultiIndexValidator"
+ assert session.message == "original-message"
+ assert session.validator == "original-validator"
+ assert session.placeholder == "original-placeholder"
+
+
+@pytest.mark.asyncio
+async def test_prompt_for_index_returns_cancel_key_as_int() -> None:
+ session = FakePromptSession(" 7 ")
+ table = render_table_base("Choices")
+
+ result = await prompt_for_index(
+ 7,
+ table,
+ prompt_session=session, # type: ignore[arg-type]
+ show_table=False,
+ cancel_key="7",
+ )
+
+ assert result == 7
+
+
+@pytest.mark.asyncio
+async def test_prompt_for_index_returns_multiple_indexes_with_custom_separator() -> None:
+ session = FakePromptSession("0 ; 2 ; 4")
+ table = render_table_base("Choices")
+
+ result = await prompt_for_index(
+ 5,
+ table,
+ prompt_session=session, # type: ignore[arg-type]
+ show_table=False,
+ number_selections=3,
+ separator=";",
+ allow_duplicates=True,
+ )
+
+ assert result == [0, 2, 4]
+ _, kwargs = session.calls[0]
+ assert kwargs["placeholder"] == "Enter 3 selections separated by ';'"
+ assert kwargs["validator"].__class__.__name__ == "MultiIndexValidator"
+
+
+@pytest.mark.asyncio
+async def test_prompt_for_selection_returns_single_key_and_prints_table(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ fake_console = CaptureConsole()
+ monkeypatch.setattr(selection_module, "console", fake_console)
+ session = FakePromptSession(" DEV ")
+ table = render_table_base("Choices")
+
+ result = await prompt_for_selection(
+ ["DEV", "PROD"],
+ table,
+ default_selection="PROD",
+ prompt_session=session, # type: ignore[arg-type]
+ prompt_message="Select > ",
+ show_table=True,
+ number_selections=1,
+ cancel_key="Q",
+ )
+
+ assert result == "DEV"
+ assert len(fake_console.printed) == 1
+ _, kwargs = session.calls[0]
+ assert kwargs["default"] == "PROD"
+ assert kwargs["placeholder"] == "Enter selection"
+ assert kwargs["validator"].__class__.__name__ == "MultiKeyValidator"
+
+
+@pytest.mark.asyncio
+async def test_prompt_for_selection_returns_cancel_key() -> None:
+ session = FakePromptSession(" q ")
+ table = render_table_base("Choices")
+
+ result = await prompt_for_selection(
+ ["dev", "prod", "q"],
+ table,
+ prompt_session=session, # type: ignore[arg-type]
+ show_table=False,
+ cancel_key="q",
+ )
+
+ assert result == "q"
+
+
+@pytest.mark.asyncio
+async def test_prompt_for_selection_returns_multiple_keys_with_custom_separator() -> None:
+ session = FakePromptSession("dev | prod | stage")
+ table = render_table_base("Choices")
+
+ result = await prompt_for_selection(
+ ["dev", "prod", "stage"],
+ table,
+ prompt_session=session, # type: ignore[arg-type]
+ show_table=False,
+ number_selections="*",
+ separator="|",
+ allow_duplicates=True,
+ )
+
+ assert result == ["dev", "prod", "stage"]
+ _, kwargs = session.calls[0]
+ assert kwargs["placeholder"] == "Enter selections separated by '|'"
+
+
+@pytest.mark.asyncio
+async def test_select_value_from_list_returns_single_and_multiple_values(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ prompt_calls: list[dict[str, Any]] = []
+
+ async def fake_prompt_for_index(max_index: int, table: Table, **kwargs: Any) -> Any:
+ prompt_calls.append({"max_index": max_index, "table": table, **kwargs})
+ return [0, 2] if kwargs["number_selections"] != 1 else 1
+
+ monkeypatch.setattr(selection_module, "prompt_for_index", fake_prompt_for_index)
+
+ single = await select_value_from_list(
+ "Languages",
+ ["python", "rust", "go"],
+ default_selection="1",
+ number_selections=1,
+ )
+ multiple = await select_value_from_list(
+ "Languages",
+ ["python", "rust", "go"],
+ default_selection="0,2",
+ number_selections=2,
+ )
+
+ assert single == "rust"
+ assert multiple == ["python", "go"]
+ assert prompt_calls[0]["max_index"] == 2
+ assert prompt_calls[0]["default_selection"] == "1"
+ assert prompt_calls[1]["number_selections"] == 2
+
+
+@pytest.mark.asyncio
+async def test_select_key_from_dict_delegates_to_prompt_for_selection(
+ monkeypatch: pytest.MonkeyPatch,
+ sample_options: dict[str, SelectionOption],
+) -> None:
+ fake_console = CaptureConsole()
+ monkeypatch.setattr(selection_module, "console", fake_console)
+ calls: list[dict[str, Any]] = []
+
+ async def fake_prompt_for_selection(keys: Any, table: Table, **kwargs: Any) -> str:
+ calls.append({"keys": list(keys), "table": table, **kwargs})
+ return "prod"
+
+ monkeypatch.setattr(
+ selection_module, "prompt_for_selection", fake_prompt_for_selection
+ )
+ table = render_table_base("Environments")
+
+ result = await select_key_from_dict(
+ sample_options,
+ table,
+ default_selection="dev",
+ number_selections=1,
+ cancel_key="q",
+ )
+
+ assert result == "prod"
+ assert len(fake_console.printed) == 1
+ assert calls[0]["keys"] == ["dev", "prod", "stage"]
+ assert calls[0]["default_selection"] == "dev"
+ assert calls[0]["cancel_key"] == "q"
+
+
+@pytest.mark.asyncio
+async def test_select_value_from_dict_returns_single_and_multiple_values(
+ monkeypatch: pytest.MonkeyPatch,
+ sample_options: dict[str, SelectionOption],
+) -> None:
+ fake_console = CaptureConsole()
+ monkeypatch.setattr(selection_module, "console", fake_console)
+ responses: list[Any] = ["prod", ["dev", "stage"]]
+
+ async def fake_prompt_for_selection(keys: Any, table: Table, **kwargs: Any) -> Any:
+ return responses.pop(0)
+
+ monkeypatch.setattr(
+ selection_module, "prompt_for_selection", fake_prompt_for_selection
+ )
+ table = render_table_base("Environments")
+
+ single = await select_value_from_dict(sample_options, table)
+ multiple = await select_value_from_dict(
+ sample_options,
+ table,
+ number_selections=2,
+ separator=",",
+ )
+
+ assert single == "prod-value"
+ assert multiple == ["dev-value", "stage-value"]
+ assert len(fake_console.printed) == 2
+
+
+@pytest.mark.asyncio
+async def test_get_selection_from_dict_menu_builds_table_and_returns_selected_value(
+ monkeypatch: pytest.MonkeyPatch,
+ sample_options: dict[str, SelectionOption],
+) -> None:
+ calls: list[dict[str, Any]] = []
+
+ async def fake_select_value_from_dict(
+ *, selections: dict[str, SelectionOption], table: Table, **kwargs: Any
+ ) -> str:
+ calls.append({"selections": selections, "table": table, **kwargs})
+ return "prod-value"
+
+ monkeypatch.setattr(
+ selection_module, "select_value_from_dict", fake_select_value_from_dict
+ )
+
+ result = await get_selection_from_dict_menu(
+ "Environments",
+ sample_options,
+ default_selection="prod",
+ number_selections=1,
+ cancel_key="q",
+ )
+
+ assert result == "prod-value"
+ assert calls[0]["selections"] is sample_options
+ assert calls[0]["table"].title == "Environments"
+ assert calls[0]["default_selection"] == "prod"
+ assert calls[0]["cancel_key"] == "q"
diff --git a/tests/test_validators/test_command_validator.py b/tests/test_validators/test_command_validator.py
index 0fd2d5e..1b8cf51 100644
--- a/tests/test_validators/test_command_validator.py
+++ b/tests/test_validators/test_command_validator.py
@@ -1,42 +1,50 @@
+from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from prompt_toolkit.document import Document
from prompt_toolkit.validation import ValidationError
+from falyx.routing import RouteKind
from falyx.validators import CommandValidator
@pytest.mark.asyncio
async def test_command_validator_validates_command():
fake_falyx = AsyncMock()
- fake_falyx.get_command.return_value = (False, object(), (), {})
+ fake_route = SimpleNamespace()
+ fake_route.is_preview = False
+ fake_route.kind = RouteKind.NAMESPACE_HELP
+ fake_falyx.prepare_route.return_value = (fake_route, (), {}, {})
validator = CommandValidator(fake_falyx, "Invalid!")
await validator.validate_async(Document("valid"))
- fake_falyx.get_command.assert_awaited_once()
+ fake_falyx.prepare_route.assert_awaited_once()
@pytest.mark.asyncio
async def test_command_validator_rejects_invalid_command():
fake_falyx = AsyncMock()
- fake_falyx.get_command.return_value = (False, None, (), {})
+ fake_falyx.prepare_route.return_value = (None, (), {}, {})
validator = CommandValidator(fake_falyx, "Invalid!")
with pytest.raises(ValidationError):
- await validator.validate_async(Document("not_a_command"))
+ await validator.validate_async(Document(""))
with pytest.raises(ValidationError):
- await validator.validate_async(Document(""))
+ await validator.validate_async(Document("not_a_command"))
@pytest.mark.asyncio
async def test_command_validator_is_preview():
fake_falyx = AsyncMock()
- fake_falyx.get_command.return_value = (True, None, (), {})
+ 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!")
await validator.validate_async(Document("?preview_command"))
- fake_falyx.get_command.assert_awaited_once_with(
+ fake_falyx.prepare_route.assert_awaited_once_with(
"?preview_command", from_validate=True
)