diff --git a/falyx/action/action.py b/falyx/action/action.py
index 52edcb6..d4714f2 100644
--- a/falyx/action/action.py
+++ b/falyx/action/action.py
@@ -206,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 c26d89d..b7b5711 100644
--- a/falyx/action/action_factory.py
+++ b/falyx/action/action_factory.py
@@ -30,6 +30,8 @@ Example:
inject_last_result=True,
)
"""
+from __future__ import annotations
+
from typing import Any, Callable
from rich.tree import Tree
@@ -174,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 318790f..00fc057 100644
--- a/falyx/action/action_group.py
+++ b/falyx/action/action_group.py
@@ -244,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 51d5a8a..6784265 100644
--- a/falyx/action/action_mixins.py
+++ b/falyx/action/action_mixins.py
@@ -7,7 +7,7 @@ maintaining a mutable list of named actions—such as adding, removing, or retri
actions by name—without duplicating logic across composite action types.
"""
-from typing import Any, Sequence
+from typing import Sequence
from falyx.action.base_action import BaseAction
diff --git a/falyx/action/base_action.py b/falyx/action/base_action.py
index af8bd86..4883a93 100644
--- a/falyx/action/base_action.py
+++ b/falyx/action/base_action.py
@@ -125,10 +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:
+ 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
@@ -142,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):
@@ -181,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 751a0b7..a74d069 100644
--- a/falyx/action/chained_action.py
+++ b/falyx/action/chained_action.py
@@ -318,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 cae49e3..10750e6 100644
--- a/falyx/action/confirm_action.py
+++ b/falyx/action/confirm_action.py
@@ -89,7 +89,7 @@ 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,
@@ -267,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 9daa8bc..75aaef8 100644
--- a/falyx/action/fallback_action.py
+++ b/falyx/action/fallback_action.py
@@ -35,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
@@ -83,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 7099460..60ee22f 100644
--- a/falyx/action/http_action.py
+++ b/falyx/action/http_action.py
@@ -7,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
@@ -80,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
@@ -103,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]:
@@ -165,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 b6b393c..6fc56ba 100644
--- a/falyx/action/io_action.py
+++ b/falyx/action/io_action.py
@@ -14,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
@@ -168,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 2e4c3a9..20019f2 100644
--- a/falyx/action/literal_input_action.py
+++ b/falyx/action/literal_input_action.py
@@ -76,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 7a5a770..dabaeeb 100644
--- a/falyx/action/load_file_action.py
+++ b/falyx/action/load_file_action.py
@@ -35,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
@@ -261,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 91184bb..b793985 100644
--- a/falyx/action/menu_action.py
+++ b/falyx/action/menu_action.py
@@ -36,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
@@ -119,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,
@@ -245,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 14a1fa4..c7bd421 100644
--- a/falyx/action/process_action.py
+++ b/falyx/action/process_action.py
@@ -177,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 15437eb..025d7d8 100644
--- a/falyx/action/process_pool_action.py
+++ b/falyx/action/process_pool_action.py
@@ -58,6 +58,14 @@ 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.
@@ -230,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 b423ce1..c7f47ff 100644
--- a/falyx/action/prompt_menu_action.py
+++ b/falyx/action/prompt_menu_action.py
@@ -10,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
@@ -187,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 99493c3..a20353e 100644
--- a/falyx/action/save_file_action.py
+++ b/falyx/action/save_file_action.py
@@ -19,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
@@ -89,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",
@@ -98,6 +100,7 @@ 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.
@@ -112,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)
@@ -291,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 32730e1..6b76639 100644
--- a/falyx/action/select_file_action.py
+++ b/falyx/action/select_file_action.py
@@ -113,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
@@ -183,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:
@@ -290,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 b7e06dc..ec65385 100644
--- a/falyx/action/selection_action.py
+++ b/falyx/action/selection_action.py
@@ -30,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
@@ -138,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__(
@@ -556,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 35797a8..66eaac2 100644
--- a/falyx/action/shell_action.py
+++ b/falyx/action/shell_action.py
@@ -101,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 9c27f96..3e5d3cf 100644
--- a/falyx/action/signal_action.py
+++ b/falyx/action/signal_action.py
@@ -23,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
@@ -90,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 da5d4f4..fc7ac10 100644
--- a/falyx/action/user_input_action.py
+++ b/falyx/action/user_input_action.py
@@ -25,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
@@ -132,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/command.py b/falyx/command.py
index ef57ce7..adf141e 100644
--- a/falyx/command.py
+++ b/falyx/command.py
@@ -191,7 +191,7 @@ class Command(BaseModel):
spinner_type: str = "dots"
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)
@@ -307,6 +307,7 @@ class Command(BaseModel):
@field_validator("action", mode="before")
@classmethod
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):
@@ -314,6 +315,7 @@ class Command(BaseModel):
raise TypeError("Action must be a callable or an instance of BaseAction")
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(
@@ -382,6 +384,22 @@ 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:
"""Execute the command's underlying action with lifecycle management.
@@ -430,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()
@@ -486,6 +499,18 @@ 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."""
@@ -557,6 +582,7 @@ class Command(BaseModel):
)
def log_summary(self) -> None:
+ """Log a summary of the command execution if context is available."""
if self._context:
self._context.log_summary()
@@ -597,6 +623,7 @@ class Command(BaseModel):
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):
@@ -855,3 +882,131 @@ class Command(BaseModel):
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
index b512d24..2037eb2 100644
--- a/falyx/command_executor.py
+++ b/falyx/command_executor.py
@@ -82,7 +82,7 @@ class CommandExecutor:
- Emit optional execution summaries
Attributes:
- options (OptionsManager): Shared options manager used to apply scoped
+ options_manager (OptionsManager): Shared options manager used to apply scoped
execution overrides.
hooks (HookManager): Hook manager for executor-level lifecycle hooks.
"""
@@ -90,10 +90,10 @@ class CommandExecutor:
def __init__(
self,
*,
- options: OptionsManager,
+ options_manager: OptionsManager,
hooks: HookManager,
) -> None:
- self.options = options
+ self.options_manager = options_manager
self.hooks = hooks
def _debug_hooks(self, command: Command) -> None:
@@ -271,7 +271,7 @@ class CommandExecutor:
try:
await self.hooks.trigger(HookType.BEFORE, context)
- with self.options.override_namespace(
+ with self.options_manager.override_namespace(
overrides=overrides,
namespace_name="execution",
):
diff --git a/falyx/command_runner.py b/falyx/command_runner.py
index 2e60f0a..0948021 100644
--- a/falyx/command_runner.py
+++ b/falyx/command_runner.py
@@ -92,7 +92,7 @@ class CommandRunner:
Attributes:
command (Command): The command executed by this runner.
program (str): Program name used in CLI usage text and help output.
- options (OptionsManager): Shared options manager used by the command,
+ 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.
@@ -105,7 +105,7 @@ class CommandRunner:
command: Command,
*,
program: str | None = None,
- options: OptionsManager | None = None,
+ options_manager: OptionsManager | None = None,
runner_hooks: HookManager | None = None,
console: Console | None = None,
) -> None:
@@ -120,7 +120,7 @@ class CommandRunner:
program (str | None): Program name used in CLI usage text, invocation-path
rendering, and built-in help output. If `None`, an empty program name is
used.
- options (OptionsManager | None): Optional shared options manager. If
+ options_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.
@@ -129,23 +129,26 @@ class CommandRunner:
"""
self.command = command
self.program = program or ""
- self.options = self._get_options(options)
+ 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
+ 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)
+ 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=self.options,
+ options_manager=self.options_manager,
hooks=self.runner_hooks,
)
- self.options.from_mapping(values={}, namespace_name="execution")
+ 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:
@@ -155,13 +158,18 @@ class CommandRunner:
else:
raise NotAFalyxError("console must be an instance of rich.Console or None.")
- def _get_options(self, options) -> OptionsManager:
- if options is None:
+ def _get_options_manager(
+ self,
+ options_manager: OptionsManager | None,
+ ) -> OptionsManager:
+ if options_manager is None:
return OptionsManager()
- elif isinstance(options, OptionsManager):
- return options
+ elif isinstance(options_manager, OptionsManager):
+ return options_manager
else:
- raise NotAFalyxError("options must be an instance of OptionsManager or None.")
+ raise NotAFalyxError(
+ "options_manager must be an instance of OptionsManager or None."
+ )
def _get_hooks(self, hooks) -> HookManager:
if hooks is None:
@@ -295,7 +303,7 @@ class CommandRunner:
*,
program: str | None = None,
runner_hooks: HookManager | None = None,
- options: OptionsManager | None = None,
+ options_manager: OptionsManager | None = None,
console: Console | None = None,
) -> CommandRunner:
"""Create a `CommandRunner` from an existing `Command` instance.
@@ -311,7 +319,7 @@ class CommandRunner:
used.
runner_hooks (HookManager | None): Optional executor-level hook manager
for the runner.
- options (OptionsManager | None): Optional shared options manager.
+ options_manager (OptionsManager | None): Optional shared options manager.
console (Console | None): Optional Rich console for output.
Returns:
@@ -325,10 +333,14 @@ class CommandRunner:
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.")
- return cls(
- command=command,
+ bound_command = command.clone_with_overrides(
+ options_manager=options_manager,
program=program,
- options=options,
+ )
+ return cls(
+ command=bound_command,
+ program=program,
+ options_manager=options_manager,
runner_hooks=runner_hooks,
console=console,
)
@@ -357,7 +369,7 @@ class CommandRunner:
spinner_type: str = "dots",
spinner_style: str = OneColors.CYAN,
spinner_speed: float = 1.0,
- options: OptionsManager | None = None,
+ options_manager: OptionsManager | None = None,
command_hooks: HookManager | None = None,
before_hooks: list[Callable] | None = None,
success_hooks: list[Callable] | None = None,
@@ -415,7 +427,7 @@ class CommandRunner:
spinner_type (str): Spinner animation type.
spinner_style (str): Spinner style.
spinner_speed (float): Spinner speed multiplier.
- options (OptionsManager | None): Shared options manager for the command
+ options_manager (OptionsManager | None): Shared options manager for the command
and runner.
command_hooks (HookManager | None): Optional hook manager for the built
command itself.
@@ -473,7 +485,7 @@ class CommandRunner:
- Command construction is delegated to `Command.build()` so command
configuration remains centralized.
"""
- options = options or OptionsManager()
+ options_manager = options_manager or OptionsManager()
command = Command.build(
key=key,
description=description,
@@ -499,7 +511,7 @@ class CommandRunner:
retry=retry,
retry_all=retry_all,
retry_policy=retry_policy,
- options_manager=options,
+ options_manager=options_manager,
hooks=command_hooks,
before_hooks=before_hooks,
success_hooks=success_hooks,
@@ -525,7 +537,7 @@ class CommandRunner:
return cls(
command=command,
- options=options,
+ options_manager=options_manager,
runner_hooks=runner_hooks,
console=console,
)
diff --git a/falyx/completer.py b/falyx/completer.py
index 7852513..2d1e7af 100644
--- a/falyx/completer.py
+++ b/falyx/completer.py
@@ -83,7 +83,7 @@ class FalyxCompleter(Completer):
- 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 help flags while routing.
+ - 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.
@@ -137,15 +137,14 @@ class FalyxCompleter(Completer):
# 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)
- # Only here should namespace-level help/TLDR be suggested.
- # TODO: better completer in FalyxParser
- if not route.command: # and (not route.stub or route.stub.startswith("-")):
- for flag in route.namespace.parser._options_by_dest:
- if flag.startswith(route.stub):
- suggestions.append(flag)
-
if route.is_preview:
suggestions = [f"?{s}" for s in suggestions]
current_stub = f"?{route.stub}" if route.stub else "?"
@@ -171,7 +170,7 @@ class FalyxCompleter(Completer):
except Exception:
return
- yield from self._yield_lcp_completions(suggestions, route.stub)
+ yield from self._yield_completions(suggestions, route.stub)
def _suggest_namespace_entries(self, namespace: Falyx, prefix: str) -> list[str]:
"""Return matching visible entry names for a namespace prefix.
@@ -190,6 +189,7 @@ class FalyxCompleter(Completer):
"""
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
@@ -207,7 +207,31 @@ class FalyxCompleter(Completer):
return f'"{text}"'
return text
- def _yield_lcp_completions(self, suggestions, stub) -> Iterable[Completion]:
+ 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).
+ """
+ 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:
diff --git a/falyx/completer_types.py b/falyx/completer_types.py
index cbe13a3..0bb04b7 100644
--- a/falyx/completer_types.py
+++ b/falyx/completer_types.py
@@ -59,6 +59,9 @@ class CompletionRoute:
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
@@ -81,6 +84,7 @@ class CompletionRoute:
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
diff --git a/falyx/console.py b/falyx/console.py
index ff66566..0e2cca7 100644
--- a/falyx/console.py
+++ b/falyx/console.py
@@ -2,6 +2,7 @@
"""Global console instance for Falyx CLI applications."""
from rich.console import Console
+from falyx.exceptions import FalyxError
from falyx.themes import OneColors, get_nord_theme
console = Console(color_system="truecolor", theme=get_nord_theme())
@@ -13,6 +14,9 @@ def print_error(
*,
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/exceptions.py b/falyx/exceptions.py
index 2ff1a8d..30c2175 100644
--- a/falyx/exceptions.py
+++ b/falyx/exceptions.py
@@ -209,12 +209,38 @@ class MissingValueError(ArgumentParsingError):
def __init__(
self,
dest: str,
- expected_count: int | None = None,
+ 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.dest = dest
+ 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):
diff --git a/falyx/execution_registry.py b/falyx/execution_registry.py
index fe2f0de..01d1492 100644
--- a/falyx/execution_registry.py
+++ b/falyx/execution_registry.py
@@ -256,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 7c70791..98267f0 100644
--- a/falyx/falyx.py
+++ b/falyx/falyx.py
@@ -64,11 +64,12 @@ import asyncio
import logging
import shlex
import sys
+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
@@ -103,6 +104,7 @@ from falyx.exceptions import (
CommandArgumentError,
EntryNotFoundError,
FalyxError,
+ FalyxOptionError,
InvalidActionError,
InvalidHookError,
NotAFalyxError,
@@ -115,7 +117,8 @@ 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, FalyxParser, ParseResult
+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
@@ -128,7 +131,6 @@ from falyx.validators import CommandValidator
from falyx.version import __version__
-# TODO: better OptionsManager determination (assert same instance across a namespace)
class Falyx:
"""Primary controller for Falyx CLI applications.
@@ -206,7 +208,7 @@ class Falyx:
builtins (dict[str, Command]): Registered built-in commands such as help,
preview, and version.
namespaces (dict[str, FalyxNamespace]): Registered nested namespaces.
- options (OptionsManager): Shared runtime option manager.
+ 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.
@@ -254,7 +256,7 @@ class Falyx:
force_confirm: bool = False,
verbose: bool = False,
debug_hooks: bool = False,
- options: OptionsManager | None = None,
+ 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,
@@ -328,7 +330,7 @@ class Falyx:
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 (OptionsManager | None): Shared options manager for the application.
+ 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.
@@ -355,7 +357,7 @@ class Falyx:
option from the root parser.
Raises:
- FalyxError: If the provided options object is invalid or other core runtime
+ FalyxError: If the provided options_manager object is invalid or other core runtime
configuration is inconsistent.
Notes:
@@ -399,9 +401,9 @@ class Falyx:
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(options)
+ self._validate_options_manager(options_manager)
self._prompt_session: PromptSession | None = None
- self.options.set("mode", FalyxMode.COMMAND)
+ 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
@@ -419,9 +421,9 @@ class Falyx:
self.default_to_menu: bool = default_to_menu
self.simple_usage: bool = simple_usage
self._register_default_builtins()
- self._register_options()
+ self._register_runtime_options()
self._executor = CommandExecutor(
- options=self.options,
+ options_manager=self.options_manager,
hooks=self.hooks,
)
self.disable_verbose_option: bool = disable_verbose_option
@@ -429,6 +431,38 @@ class Falyx:
self.disable_never_prompt_option: bool = disable_never_prompt_option
self.parser: FalyxParser = FalyxParser(self)
+ def __str__(self) -> str:
+ return (
+ f"Falyx(program='{self.program}', "
+ f"title='{self.title}', "
+ f"description='{self.description}')"
+ )
+
+ def __repr__(self) -> str:
+ return self.__str__()
+
+ 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:
+ 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,
*,
@@ -487,7 +521,7 @@ class Falyx:
program=self.program,
program_style=self.program_style,
typed_path=[],
- mode=self.options.get("mode"),
+ mode=self.options_manager.get("mode"),
)
@property
@@ -497,11 +531,11 @@ class Falyx:
Returns:
bool: `True` when the active mode is not `FalyxMode.MENU`.
"""
- return self.options.get("mode") != FalyxMode.MENU
+ return self.options_manager.get("mode") != FalyxMode.MENU
- def _validate_options(
+ def _validate_options_manager(
self,
- options: OptionsManager | None = None,
+ options_manager: OptionsManager | None = None,
) -> None:
"""Validate and install the shared options manager.
@@ -509,44 +543,47 @@ class Falyx:
stored on the instance.
Args:
- options (OptionsManager | None): Optional options manager to reuse.
+ options_manager (OptionsManager | None): Optional options manager to reuse.
Raises:
- NotAFalyxError: If `options` is provided but is not an `OptionsManager`.
+ NotAFalyxError: If `options_manager` is provided but is not an `OptionsManager`.
"""
- self.options: OptionsManager = options or OptionsManager()
- if not isinstance(self.options, OptionsManager):
- raise NotAFalyxError("options must be an instance of 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_options(self) -> None:
+ def _register_runtime_options(self) -> None:
"""Seed default application options and execution namespace values.
- This method ensures that core runtime flags such as mode, prompt behavior,
+ This method ensures that core runtime flags such as prompt behavior,
menu visibility, and program display metadata exist in the shared options
manager.
"""
- self.options.from_mapping(values={}, namespace_name="execution")
+ 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.get("never_prompt"):
- self.options.set("never_prompt", self._never_prompt)
+ if not self.options_manager.has_option("never_prompt"):
+ self.options_manager.set("never_prompt", self._never_prompt, "root")
- if not self.options.get("force_confirm"):
- self.options.set("force_confirm", self._force_confirm)
+ if not self.options_manager.has_option("force_confirm"):
+ self.options_manager.set("force_confirm", self._force_confirm, "root")
- if not self.options.get("verbose"):
- self.options.set("verbose", self._verbose)
+ if not self.options_manager.has_option("verbose"):
+ self.options_manager.set("verbose", self._verbose, "root")
- if not self.options.get("debug_hooks"):
- self.options.set("debug_hooks", self._debug_hooks)
+ if not self.options_manager.has_option("debug_hooks"):
+ self.options_manager.set("debug_hooks", self._debug_hooks, "root")
- if not self.options.get("hide_menu_table"):
- self.options.set("hide_menu_table", self._hide_menu_table)
+ 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.get("program"):
- self.options.set("program", self.program)
+ if not self.options_manager.has_option("program"):
+ self.options_manager.set("program", self.program)
- if not self.options.get("program_style"):
- self.options.set("program_style", self.program_style)
+ 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]:
@@ -670,7 +707,7 @@ class Falyx:
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.",
)
@@ -744,7 +781,7 @@ class Falyx:
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,
)
@@ -1313,7 +1350,7 @@ class Falyx:
style=OneColors.LIGHT_YELLOW,
argument_config=add_help_arguments,
ignore_in_history=True,
- options_manager=self.options,
+ options_manager=self.options_manager,
program=self.program,
)
@@ -1364,7 +1401,7 @@ class Falyx:
aliases=["PREVIEW"],
action=Action("Preview", self._preview),
style=OneColors.GREEN,
- options_manager=self.options,
+ options_manager=self.options_manager,
program=self.program,
help_text="Preview the execution of a command without running it.",
argument_config=add_preview_argument,
@@ -1388,7 +1425,7 @@ class Falyx:
action=Action("Version", self._render_version),
style=self.version_style,
ignore_in_history=True,
- options_manager=self.options,
+ options_manager=self.options_manager,
program=self.program,
help_text=f"Show the {self.program} version.",
)
@@ -1664,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,
)
@@ -1726,41 +1763,56 @@ class Falyx:
help_text="Go back to the previous menu.",
)
- def add_commands(self, commands: list[Command] | list[dict]) -> None:
+ 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."
)
+ return added_commands
- def add_command_from_command(self, command: Command) -> None:
- """Register an already-built `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_aliases(command.key, command.aliases)
- self.commands[command.key] = command
+ 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,
@@ -1903,7 +1955,7 @@ class Falyx:
auto_args=auto_args,
arg_metadata=arg_metadata,
simple_help_signature=simple_help_signature,
- options_manager=self.options,
+ options_manager=self.options_manager,
ignore_in_history=ignore_in_history,
program=self.program,
)
@@ -2146,7 +2198,7 @@ class Falyx:
program=self.program,
program_style=self.program_style,
typed_path=[],
- mode=mode or self.options.get("mode"),
+ mode=mode or self.options_manager.get("mode"),
is_preview=is_preview,
)
@@ -2253,6 +2305,7 @@ class Falyx:
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)
@@ -2286,6 +2339,10 @@ class Falyx:
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(
@@ -2295,15 +2352,22 @@ class Falyx:
kwargs,
execution_args,
)
- return await self._executor.execute(
- command=route.command,
- args=args,
- kwargs=kwargs or {},
- execution_args=execution_args or {},
- raise_on_error=raise_on_error,
- wrap_errors=wrap_errors,
- summary_last_result=summary_last_result,
+ 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,
@@ -2372,15 +2436,23 @@ class Falyx:
assert route is not None, "prepare_route should never return None."
- 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,
+ 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,
@@ -2409,21 +2481,57 @@ class Falyx:
"""
namespace = self
route_context = invocation_context
- remaining = list(committed_tokens)
+ 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,
- leaf_argv=[],
- stub=head if not remaining else stub,
+ remaining_argv=remaining_in_namespace,
+ stub=head,
cursor_at_end_of_token=cursor_at_end_of_token,
expecting_entry=True,
is_preview=is_preview,
@@ -2453,6 +2561,7 @@ class Falyx:
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,
@@ -2465,6 +2574,8 @@ class Falyx:
*,
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.
@@ -2485,7 +2596,13 @@ class Falyx:
"""
# 1. Namespace-level parsing for help/tldr flags and root/session options
parse_result = self.parser.parse_args(tokens)
- self.parser.apply_to_options(parse_result, self.options)
+ 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
@@ -2507,12 +2624,22 @@ class Falyx:
)
# 3. No more tokens -> this namespace itself was targeted
- if not tokens:
+ 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
@@ -2534,7 +2661,11 @@ class Falyx:
# 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
+ 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
@@ -2546,6 +2677,10 @@ class Falyx:
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:
@@ -2577,12 +2712,12 @@ class Falyx:
welcome and exit messages.
"""
logger.info("Starting menu: %s", self.title)
- self.options.set("mode", FalyxMode.MENU)
+ self.options_manager.set("mode", FalyxMode.MENU)
if 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:
@@ -2608,32 +2743,20 @@ class Falyx:
if self.exit_message:
self.console.print(self.exit_message)
- def _apply_parse_result(self, result: ParseResult) -> None:
+ def _apply_root_options(self) -> None:
"""Apply parsed root/session options to runtime state.
- This updates the active mode, logging verbosity, debug-hook registration,
- and prompt behavior based on the root parse result.
-
- Args:
- result (ParseResult): Parsed root CLI result to apply.
+ This updates logging verbosity and debug-hook registration.
"""
- self.options.set("mode", result.mode)
-
- if result.verbose:
- logging.getLogger("falyx").setLevel(logging.DEBUG)
- self.options.set("verbose", True)
+ falyx_logger = logging.getLogger("falyx")
+ if self.options_manager.get("verbose", False, "root"):
+ falyx_logger.setLevel(logging.DEBUG)
else:
- self.options.set("verbose", False)
+ falyx_logger.setLevel(logging.WARNING)
- if result.debug_hooks:
- self.options.set("debug_hooks", True)
+ if self.options_manager.get("debug_hooks", False, "root"):
self.register_all_with_debug_hooks()
logger.debug("Enabling global debug hooks for all commands")
- else:
- self.options.set("debug_hooks", False)
-
- if result.never_prompt:
- self.options.set("never_prompt", True)
async def run(self, always_start_menu: bool = False) -> None:
"""Execute the Falyx application using CLI-driven dispatch.
@@ -2690,21 +2813,29 @@ class Falyx:
assert route is not None, "prepare_route should never return None."
try:
- await self._dispatch_route(
- route=route,
- args=args,
- kwargs=kwargs,
- execution_args=execution_args,
- raise_on_error=False,
- wrap_errors=True,
+ 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",
+ ):
+ 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.get("verbose"):
+ if self.options_manager.get("verbose", False, "root"):
logger.error("Error: %s", error, exc_info=True)
sys.exit(1)
except QuitSignal:
diff --git a/falyx/hook_manager.py b/falyx/hook_manager.py
index 33f7328..ec076c2 100644
--- a/falyx/hook_manager.py
+++ b/falyx/hook_manager.py
@@ -179,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/menu.py b/falyx/menu.py
index 2db157d..793af01 100644
--- a/falyx/menu.py
+++ b/falyx/menu.py
@@ -68,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):
"""
@@ -160,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/options_manager.py b/falyx/options_manager.py
index e8cf85a..b664c9c 100644
--- a/falyx/options_manager.py
+++ b/falyx/options_manager.py
@@ -37,6 +37,7 @@ Attributes:
"""
from collections import defaultdict
from contextlib import contextmanager
+from copy import deepcopy
from typing import Any, Callable, Iterator, Mapping
from falyx.logger import logger
@@ -227,21 +228,50 @@ class OptionsManager:
return _toggle
- def get_namespace_dict(self, namespace_name: str) -> dict[str, Any]:
- """Return a shallow copy of one namespace's option 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:
+ 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.
-
- Raises:
- ValueError: If the requested namespace does not exist.
"""
if namespace_name not in self.options:
- raise ValueError(f"Namespace '{namespace_name}' not found.")
- return dict(self.options[namespace_name])
+ 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(
@@ -267,9 +297,16 @@ class OptionsManager:
Raises:
ValueError: If the namespace does not already exist.
"""
- original = self.get_namespace_dict(namespace_name)
+ 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 7d29c06..f200c53 100644
--- a/falyx/parser/__init__.py
+++ b/falyx/parser/__init__.py
@@ -7,7 +7,7 @@ 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 .falyx_parser import FalyxParser
+from .falyx_parser import FalyxParser, OptionAction
from .parse_result import ParseResult
__all__ = [
@@ -15,5 +15,6 @@ __all__ = [
"ArgumentAction",
"CommandArgumentParser",
"FalyxParser",
+ "OptionAction",
"ParseResult",
]
diff --git a/falyx/parser/argument.py b/falyx/parser/argument.py
index ab0addd..fe9b3b2 100644
--- a/falyx/parser/argument.py
+++ b/falyx/parser/argument.py
@@ -32,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
@@ -155,3 +157,23 @@ class Argument:
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/command_argument_parser.py b/falyx/parser/command_argument_parser.py
index 77c8292..0fedeee 100644
--- a/falyx/parser/command_argument_parser.py
+++ b/falyx/parser/command_argument_parser.py
@@ -46,7 +46,9 @@ 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, Generator, Iterable
@@ -121,7 +123,7 @@ class _GroupBuilder:
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 = "",
@@ -129,8 +131,8 @@ class _GroupBuilder:
resolver: BaseAction | None = None,
lazy_resolver: bool = True,
suggestions: list[str] | None = None,
- ) -> None:
- self.parser.add_argument(
+ ) -> Argument:
+ return self.parser.add_argument(
*flags,
action=action,
nargs=nargs,
@@ -201,6 +203,7 @@ class CommandArgumentParser:
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] = {}
@@ -208,19 +211,26 @@ class CommandArgumentParser:
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 = 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."""
@@ -247,15 +257,16 @@ class CommandArgumentParser:
execution_options: frozenset[ExecutionOption],
) -> None:
"""Enable support for execution options like retries, summary, etc."""
- if ExecutionOption.SUMMARY in execution_options:
+ 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:
+ if ExecutionOption.RETRY in execution_options and not self._retries_enabled:
self.add_argument(
"--retries",
type=int,
@@ -277,8 +288,9 @@ class CommandArgumentParser:
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:
+ if ExecutionOption.CONFIRM in execution_options and not self._confirm_enabled:
self.add_argument(
"--confirm",
dest="force_confirm",
@@ -292,6 +304,7 @@ class CommandArgumentParser:
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."""
@@ -312,6 +325,7 @@ class CommandArgumentParser:
action=ArgumentAction.HELP,
help="Show this help message.",
dest="help",
+ choices=[],
)
self._register_argument(help)
@@ -322,6 +336,7 @@ class CommandArgumentParser:
action=ArgumentAction.TLDR,
help="Show quick usage examples.",
dest="tldr",
+ choices=[],
)
self._register_argument(tldr)
@@ -544,44 +559,47 @@ class CommandArgumentParser:
raise CommandArgumentError(
"choices must be iterable (like list, tuple, or set)"
) from error
+ 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}: 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."""
+ 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:
- coerce_value(default, expected_type)
+ 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 _validate_default_list_type(
- self, default: list[Any], expected_type: type, dest: str
- ) -> None:
- """Validate the default value type for a list."""
+ 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(item, expected_type)
+ normalized.append(coerce_value(item, expected_type))
except Exception as error:
type_name = get_type_name(expected_type)
raise CommandArgumentError(
f"invalid default list value {default!r} for '{dest}' cannot be coerced to {type_name} error: {error}"
) from error
+ return normalized
def _validate_resolver(
self, action: ArgumentAction, resolver: BaseAction | None
@@ -699,6 +717,10 @@ class CommandArgumentParser:
raise CommandArgumentError(
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(
self,
@@ -707,7 +729,7 @@ class CommandArgumentParser:
help: str,
group: str | None,
mutex_group: str | None,
- ) -> None:
+ ) -> Argument:
"""Register a store_bool_optional action with the parser."""
if len(flags) != 1:
raise CommandArgumentError(
@@ -743,6 +765,7 @@ class CommandArgumentParser:
self._register_argument(argument)
self._register_argument(negated_argument, bypass_validation=True)
+ return argument
def _register_argument(
self, argument: Argument, bypass_validation: bool = False
@@ -771,19 +794,19 @@ class CommandArgumentParser:
if argument.group:
self._arg_group_by_dest[argument.dest] = argument.group
- self._argument_groups[argument.group].dests.append(argument.dest)
+ 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.append(argument.dest)
+ 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 = "",
@@ -793,7 +816,7 @@ class CommandArgumentParser:
suggestions: list[str] | None = None,
group: str | None = None,
mutex_group: str | None = None,
- ) -> None:
+ ) -> Argument:
"""Define a new argument for the parser.
Supports positional and flagged arguments, type coercion, default values,
@@ -841,9 +864,9 @@ class CommandArgumentParser:
and default is not None
):
if isinstance(default, list):
- self._validate_default_list_type(default, type, dest)
+ default = self._normalize_default_list_type(default, type, dest)
else:
- self._validate_default_type(default, type, dest)
+ 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))
@@ -873,8 +896,9 @@ class CommandArgumentParser:
f"lazy_resolver must be a boolean, got {type_name}"
)
if action == ArgumentAction.STORE_BOOL_OPTIONAL:
- self._register_store_bool_optional(flags, dest, help, group, mutex_group)
- return None
+ return self._register_store_bool_optional(
+ flags, dest, help, group, mutex_group
+ )
argument = Argument(
flags=flags,
dest=dest,
@@ -893,6 +917,7 @@ class CommandArgumentParser:
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.
@@ -944,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
@@ -982,23 +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(
- spec.dest,
+ dest=spec.dest,
expected_count=spec.nargs,
actual_count=len(args) - index,
+ display_name=display_name,
)
- # raise CommandArgumentError(
- # f"Expected {spec.nargs} value(s) for '{spec.dest}' but got {len(args) - index}"
- # )
values = args[index : index + spec.nargs]
return values, index + spec.nargs
elif spec.nargs == "+":
if index >= len(args):
- raise MissingValueError(spec.dest, expected_count=1)
- raise CommandArgumentError(
- f"Expected at least one value for '{spec.dest}'"
+ 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])
@@ -1090,26 +1117,20 @@ class CommandArgumentParser:
if spec.nargs == "+" and len(typed) == 0:
raise MissingValueError(
dest=spec.dest,
- expected_count=1,
+ expected_count="+",
)
- # raise CommandArgumentError(
- # f"Argument '{spec.dest}' requires at least one value"
- # )
if isinstance(spec.nargs, int) and len(typed) != spec.nargs:
raise MissingValueError(
- spec.dest,
+ dest=spec.dest,
expected_count=spec.nargs,
actual_count=len(typed),
)
- # raise CommandArgumentError(
- # f"Argument '{spec.dest}' requires exactly {spec.nargs} value(s)"
- # )
if not spec.lazy_resolver or not from_validate:
try:
result[spec.dest] = await spec.resolver(*typed)
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)
@@ -1143,8 +1164,8 @@ class CommandArgumentParser:
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
@@ -1211,22 +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:
type_name = get_type_name(spec.type)
- raise CommandArgumentError(
- f"Enter a {type_name} value for '{spec.dest}'. {help_text}"
+ 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(
@@ -1280,8 +1301,8 @@ class CommandArgumentParser:
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)
@@ -1431,8 +1452,8 @@ class CommandArgumentParser:
present.append(dest)
if len(present) > 1:
- raise CommandArgumentError(
- f"Arguments in mutually exclusive group '{group.name}' "
+ raise ArgumentParsingError(
+ f"arguments in mutually exclusive group '{group.name}' "
f"cannot be used together: {', '.join(present)}"
)
@@ -1442,8 +1463,8 @@ class CommandArgumentParser:
spec = self.get_argument(dest)
if spec:
members.append(spec.flags[0] if spec.flags else dest)
- raise CommandArgumentError(
- f"One of the following is required for group '{group.name}': "
+ raise ArgumentParsingError(
+ f"one of the following is required for group '{group.name}': "
f"{', '.join(members)}"
)
@@ -1543,8 +1564,8 @@ class CommandArgumentParser:
and not arg.default
]
if missing_positionals:
- raise CommandArgumentError(
- f"Missing positional argument(s): {', '.join(missing_positionals)}"
+ raise ArgumentParsingError(
+ f"missing positional argument(s): {', '.join(missing_positionals)}"
)
# Required validation
@@ -1560,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)
@@ -1611,8 +1632,8 @@ class CommandArgumentParser:
help_text = f" help: {spec.help}" if spec.help else ""
if not result[spec.dest]:
arg_states[spec.dest].reset()
- raise CommandArgumentError(
- f"Argument '{spec.dest}' requires at least one value{help_text}"
+ raise ArgumentParsingError(
+ f"argument '{spec.dest}' requires at least one value{help_text}"
)
self._validate_mutex_groups(result)
@@ -2322,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
index 6b3ff34..b1d89b8 100644
--- a/falyx/parser/falyx_parser.py
+++ b/falyx/parser/falyx_parser.py
@@ -1,18 +1,19 @@
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
from __future__ import annotations
-from dataclasses import dataclass
-from enum import Enum
+import re
+from collections.abc import Callable
from typing import TYPE_CHECKING, Any
-from falyx.console import console
from falyx.exceptions import EntryNotFoundError, FalyxOptionError
from falyx.mode import FalyxMode
-from falyx.options_manager import OptionsManager
+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,
)
@@ -24,79 +25,6 @@ if TYPE_CHECKING:
builtin_type = type
-class OptionAction(Enum):
- STORE = "store"
- STORE_TRUE = "store_true"
- STORE_FALSE = "store_false"
- STORE_BOOL_OPTIONAL = "store_bool_optional"
- COUNT = "count"
- HELP = "help"
- TLDR = "tldr"
-
- @classmethod
- def choices(cls) -> list[OptionAction]:
- """Return a list of all argument actions."""
- return list(cls)
-
- @classmethod
- def _get_alias(cls, value: str) -> str:
- aliases = {
- "optional": "store_bool_optional",
- "true": "store_true",
- "false": "store_false",
- }
- return aliases.get(value, value)
-
- @classmethod
- def _missing_(cls, value: object) -> OptionAction:
- if not isinstance(value, str):
- raise ValueError(f"Invalid {cls.__name__}: {value!r}")
- normalized = value.strip().lower()
- alias = cls._get_alias(normalized)
- for member in cls:
- if member.value == alias:
- return member
- valid = ", ".join(member.value for member in cls)
- raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}")
-
- def __str__(self) -> str:
- """Return the string representation of the argument action."""
- return self.value
-
-
-class OptionScope(Enum):
- ROOT = "root"
- NAMESPACE = "namespace"
-
- @classmethod
- def _missing_(cls, value: object) -> OptionScope:
- if not isinstance(value, str):
- raise ValueError(f"Invalid {cls.__name__}: {value!r}")
- normalized = value.strip().lower()
- for member in cls:
- if member.value == normalized:
- return member
- valid = ", ".join(member.value for member in cls)
- raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}")
-
-
-@dataclass(slots=True)
-class 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)
-
-
class FalyxParser:
RESERVED_DESTS: set[str] = {"help", "tldr"}
@@ -106,9 +34,10 @@ class FalyxParser:
self._options: list[Option] = []
self._dest_set: set[str] = set()
self._tldr_examples: list[FalyxTLDRExample] = []
- self._add_reserved_options()
self.help_option: Option | None = None
self.tldr_option: Option | None = None
+ 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."""
@@ -119,7 +48,7 @@ class FalyxParser:
return self._options
def _add_tldr(self):
- """Add TLDR argument to the parser."""
+ """Add TLDR option to the parser."""
if "tldr" in self._dest_set:
return None
tldr = Option(
@@ -208,7 +137,7 @@ class FalyxParser:
def _add_reserved_options(self) -> None:
help = Option(
- flags=("-h", "--help", "?"),
+ flags=("-h", "--help"),
dest="help",
action=OptionAction.HELP,
help="Show root-level help output and exit.",
@@ -255,7 +184,7 @@ class FalyxParser:
flags: tuple[str, ...],
dest: str,
help: str,
- ) -> None:
+ ) -> Option:
"""Register a store_bool_optional action with the parser."""
if len(flags) != 1:
raise FalyxOptionError(
@@ -268,7 +197,7 @@ class FalyxParser:
base_flag = flags[0]
negated_flag = f"--no-{base_flag.lstrip('-')}"
- argument = Option(
+ option = Option(
flags=flags,
dest=dest,
action=OptionAction.STORE_BOOL_OPTIONAL,
@@ -277,7 +206,7 @@ class FalyxParser:
help=help,
)
- negated_argument = Option(
+ negated_option = Option(
flags=(negated_flag,),
dest=dest,
action=OptionAction.STORE_BOOL_OPTIONAL,
@@ -286,17 +215,19 @@ class FalyxParser:
help=help,
)
- self._register_option(argument)
- self._register_option(negated_argument, bypass_validation=True)
+ 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 and not bypass_validation:
+ 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 argument '{existing.dest}'"
+ f"flag '{flag}' is already used by option '{existing.dest}'"
)
self._options_by_dest[flag] = option
@@ -319,7 +250,11 @@ class FalyxParser:
if flag in self._options_by_dest:
existing = self._options_by_dest[flag]
raise FalyxOptionError(
- f"flag '{flag}' is already used by argument '{existing.dest}'"
+ 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:
@@ -389,16 +324,16 @@ class FalyxParser:
raise FalyxOptionError(f"default value cannot be set for action '{action}'.")
return default
- def _validate_default_type(
+ def _normalize_default_type(
self,
default: Any,
expected_type: Any,
dest: str,
- ) -> None:
+ ) -> Any:
if default is None:
return None
try:
- coerce_value(default, expected_type)
+ return coerce_value(default, expected_type)
except Exception as error:
type_name = get_type_name(expected_type)
raise FalyxOptionError(
@@ -408,7 +343,7 @@ class FalyxParser:
def _normalize_choices(
self,
choices: list[str] | None,
- expected_type: type,
+ expected_type: Callable[[Any], Any],
action: OptionAction,
) -> list[Any]:
if choices is None:
@@ -430,27 +365,28 @@ class FalyxParser:
raise FalyxOptionError(
"choices must be iterable (like list, tuple, or set)"
) from error
+ 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 FalyxOptionError(
f"invalid choice {choice!r} cannot be coerced to {type_name} error: {error}"
) from error
- return choices
+ return normalized
def add_option(
self,
- flags: tuple[str, ...],
- dest: str,
+ *flags: str,
action: str | OptionAction = "store",
- type: type = str,
default: Any = None,
+ type: Callable[[Any], Any] = str,
choices: list[str] | None = None,
help: str = "",
+ dest: str | None = None,
suggestions: list[str] | None = None,
- ) -> None:
+ ) -> Option:
self._validate_flags(flags)
dest = self._get_dest_from_flags(flags, dest)
if dest in self.RESERVED_DESTS:
@@ -461,7 +397,10 @@ class FalyxParser:
raise FalyxOptionError(f"duplicate option dest '{dest}'")
action = self._validate_action(action)
default = self._resolve_default(default, action)
- self._validate_default_type(default, type, dest)
+
+ 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))
@@ -476,8 +415,7 @@ class FalyxParser:
):
raise FalyxOptionError("suggestions must be a list of strings")
if action is OptionAction.STORE_BOOL_OPTIONAL:
- self._register_store_bool_optional(flags, dest, help)
- return None
+ return self._register_store_bool_optional(flags, dest, help)
option = Option(
flags=flags,
dest=dest,
@@ -489,16 +427,86 @@ class FalyxParser:
suggestions=suggestions,
)
self._register_option(option)
+ return option
- def apply_to_options(
+ def _filter_suggestions(
self,
- parse_result: ParseResult,
- options: OptionsManager,
- ) -> None:
- for dest, value in parse_result.options.items():
- options.set(dest, value, namespace_name=self_flx.namespace_name)
- for dest, value in parse_result.root_options.items():
- options.set(dest, value, namespace_name="root")
+ 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 {
@@ -510,7 +518,7 @@ class FalyxParser:
}
def _resolve_posix_bundling(self, tokens: list[str]) -> list[str]:
- """Expand POSIX-style bundled arguments into separate arguments."""
+ """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:
@@ -552,30 +560,37 @@ class FalyxParser:
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(None)
+ 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:
@@ -598,6 +613,7 @@ class FalyxParser:
)
values[option.dest] = value
+ option_states[option.dest].set_consumed()
return index + 2
raise FalyxOptionError(f"unsupported option action: {option.action}")
@@ -606,19 +622,17 @@ class FalyxParser:
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)
- values, root_values = self._default_values()
+ root_options: dict[str, Any] = {}
+ namespace_options: dict[str, Any] = {}
index = 0
while index < len(arguments):
token = arguments[index]
- # Explicit option terminator. Everything after belongs to routing/command.
- if token == "--":
- index += 1
- break
-
# First non-option is the route boundary.
if not token.startswith("-"):
break
@@ -631,20 +645,33 @@ class FalyxParser:
f"unknown option '{token}' for '{self._flx.program or self._flx.title}'"
)
- target_values = root_values if option.scope == OptionScope.ROOT else values
- index = self._consume_option(option, arguments, index, target_values)
+ 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 = values.get("help", False) or values.get("tldr", False)
+ 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,
- options=values,
- root_options=root_values,
+ root_defaults=root_defaults,
+ root_options=root_options,
+ namespace_defaults=namespace_defaults,
+ namespace_options=namespace_options,
remaining_argv=remaining_argv,
- help=values.get("help", False),
- tldr=values.get("tldr", False),
+ 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
index 81ee02a..4a5a01d 100644
--- a/falyx/parser/group.py
+++ b/falyx/parser/group.py
@@ -46,7 +46,15 @@ class ArgumentGroup:
name: str
description: str = ""
- dests: list[str] = field(default_factory=list)
+ 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)
@@ -73,4 +81,13 @@ class MutuallyExclusiveGroup:
name: str
required: bool = False
description: str = ""
- dests: list[str] = field(default_factory=list)
+ 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
index 0fcb3c6..3ce73a2 100644
--- a/falyx/parser/parse_result.py
+++ b/falyx/parser/parse_result.py
@@ -38,9 +38,11 @@ class ParseResult:
Attributes:
mode: Top-level runtime mode selected from the root parse.
raw_argv: Original argv passed into the root parser.
- options: Dictionary of parsed root-level options and their values.
+ root_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).
@@ -53,12 +55,11 @@ class ParseResult:
mode: FalyxMode
raw_argv: list[str] = field(default_factory=list)
- options: dict[str, Any] = field(default_factory=dict)
+ 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
- verbose: bool = False
- debug_hooks: bool = False
- never_prompt: bool = False
diff --git a/falyx/parser/parser_types.py b/falyx/parser/parser_types.py
index 6b275c2..40af413 100644
--- a/falyx/parser/parser_types.py
+++ b/falyx/parser/parser_types.py
@@ -15,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, 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
@@ -41,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."""
@@ -48,6 +63,13 @@ 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]
@@ -60,6 +82,14 @@ class FalyxTLDRExample:
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]
diff --git a/falyx/parser/utils.py b/falyx/parser/utils.py
index 1dc0849..627114d 100644
--- a/falyx/parser/utils.py
+++ b/falyx/parser/utils.py
@@ -12,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
@@ -87,14 +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:
+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.
diff --git a/falyx/prompt_utils.py b/falyx/prompt_utils.py
index b1e32fd..33d2ecc 100644
--- a/falyx/prompt_utils.py
+++ b/falyx/prompt_utils.py
@@ -44,6 +44,7 @@ def should_prompt_user(
*,
confirm: bool,
options: OptionsManager,
+ action_never_prompt: bool | None = None,
namespace: str = "root",
override_namespace: str = "execution",
) -> bool:
@@ -57,27 +58,30 @@ def should_prompt_user(
Args:
confirm (bool): The initial confirmation flag (e.g., from a command argument).
options (OptionsManager): The options manager to check for override flags.
- namespace (str): The primary namespace to check for options (default: "root").
- override_namespace (str): The secondary namespace for overrides (default: "execution").
+ 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)
- skip_confirm = options.get("skip_confirm", None, override_namespace)
- if skip_confirm is None:
- skip_confirm = options.get("skip_confirm", False, namespace)
-
- if never_prompt or skip_confirm:
- return False
-
return confirm or force_confirm
diff --git a/falyx/routing.py b/falyx/routing.py
index 54b5b45..42f2eb1 100644
--- a/falyx/routing.py
+++ b/falyx/routing.py
@@ -23,7 +23,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
from falyx.context import InvocationContext
from falyx.namespace import FalyxNamespace
@@ -82,6 +82,8 @@ class RouteResult:
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
@@ -93,3 +95,7 @@ class RouteResult:
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 8163ad0..cbfb942 100644
--- a/falyx/selection.py
+++ b/falyx/selection.py
@@ -11,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
@@ -43,6 +45,14 @@ 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."""
@@ -97,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,
diff --git a/falyx/utils.py b/falyx/utils.py
index 43a25f1..7f121de 100644
--- a/falyx/utils.py
+++ b/falyx/utils.py
@@ -91,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))
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_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_command.py b/tests/test_command.py
index 8056f37..83adad0 100644
--- a/tests/test_command.py
+++ b/tests/test_command.py
@@ -1,16 +1,89 @@
# test_command.py
+import logging
+from collections.abc import Callable
+from types import SimpleNamespace
+from typing import Any
+
import pytest
from pydantic import ValidationError
-from falyx.action import Action, BaseIOAction, ChainedAction
+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()
@@ -18,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"
@@ -32,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."""
@@ -185,3 +255,642 @@ def test_command_bad_options_manager():
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_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_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
index 692a5fb..5e42dde 100644
--- a/tests/test_falyx/test_execute_command.py
+++ b/tests/test_falyx/test_execute_command.py
@@ -2,6 +2,8 @@ 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
@@ -9,18 +11,32 @@ async def test_execute_command():
"""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.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."""
@@ -34,7 +50,6 @@ async def test_execute_command_recover():
raise RuntimeError("Random failure!")
return "ok"
- # Add a command that raises an exception
falyx.add_command(
key="E",
description="Error Command",
@@ -44,3 +59,66 @@ async def test_execute_command_recover():
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 58d936a..6f8f3c7 100644
--- a/tests/test_falyx/test_help.py
+++ b/tests/test_falyx/test_help.py
@@ -90,6 +90,6 @@ async def test_help_command_bad_argument(capsys):
flx.add_command("U", "Untagged Command", untagged_command)
with pytest.raises(
- CommandArgumentError, match="Unexpected positional argument: nonexistent_tag"
+ 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_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 f599fb6..b12e88d 100644
--- a/tests/test_falyx/test_run.py
+++ b/tests/test_falyx/test_run.py
@@ -7,7 +7,6 @@ from rich.text import Text
from falyx import Falyx
from falyx.console import console as falyx_console
from falyx.exceptions import FalyxError
-from falyx.parser import ParseResult
from falyx.signals import BackSignal, CancelSignal, FlowSignal, HelpSignal, QuitSignal
@@ -107,27 +106,25 @@ async def test_run_default_to_menu_help(flx):
async def test_run_debug_hooks(flx):
sys.argv = ["falyx", "--debug-hooks", "T"]
- assert flx.options.get("debug_hooks") is False
+ assert flx.options_manager.get("debug_hooks", namespace_name="root") is False
with pytest.raises(SystemExit):
await flx.run()
- assert flx.options.get("debug_hooks") is True
+ 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.get("never_prompt") is False
+ assert flx.options_manager.get("never_prompt", namespace_name="root") is False
with pytest.raises(SystemExit):
await flx.run()
- falyx_console.print(flx.options.get_namespace_dict("default"))
-
- assert flx.options.get("debug_hooks") is False
- assert flx.options.get("never_prompt") is True
+ 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
@@ -253,3 +250,70 @@ async def test_run_preview(flx):
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_parsers/test_command_argument_parser.py b/tests/test_parsers/test_command_argument_parser.py
index 96a273f..6c0e901 100644
--- a/tests/test_parsers/test_command_argument_parser.py
+++ b/tests/test_parsers/test_command_argument_parser.py
@@ -5,7 +5,7 @@ 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 ArgumentAction, CommandArgumentParser
+from falyx.parser import Argument, ArgumentAction, CommandArgumentParser
from falyx.signals import HelpSignal
@@ -1009,3 +1009,20 @@ def test_add_argument_invalid_lazy_resolver():
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
index 3ef0f42..f6a3367 100644
--- a/tests/test_parsers/test_execution_option_registration.py
+++ b/tests/test_parsers/test_execution_option_registration.py
@@ -34,10 +34,6 @@ def test_enable_execution_options_registers_retry_flags():
def test_enable_execution_options_invalid_double_registration_raises():
parser = CommandArgumentParser()
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
- with pytest.raises(
- CommandArgumentError, match="destination 'summary' is already defined"
- ):
- parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
with pytest.raises(
CommandArgumentError,
@@ -68,9 +64,10 @@ def test_register_execution_dest_rejects_duplicates():
parser.add_argument("--summary", action="store_true")
with pytest.raises(
- CommandArgumentError, match="destination 'summary' is already defined"
+ CommandArgumentError,
+ match="destination 'summary' is already registered as an execution argument",
):
- parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
+ parser._register_execution_dest("summary")
@pytest.mark.asyncio
diff --git a/tests/test_parsers/test_resolve_args.py b/tests/test_parsers/test_resolve_args.py
index faf0e1d..528c1f2 100644
--- a/tests/test_parsers/test_resolve_args.py
+++ b/tests/test_parsers/test_resolve_args.py
@@ -76,9 +76,10 @@ async def test_resolve_args_raises_on_conflicting_execution_option():
)
with pytest.raises(
- CommandArgumentError, match="destination 'summary' is already defined"
+ CommandArgumentError,
+ match="destination 'summary' is already registered as an execution argument",
):
- command.arg_parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
+ command.arg_parser._register_execution_dest("summary")
@pytest.mark.asyncio
diff --git a/tests/test_runner/test_command_runner.py b/tests/test_runner/test_command_runner.py
index 3d339c4..3d8199f 100644
--- a/tests/test_runner/test_command_runner.py
+++ b/tests/test_runner/test_command_runner.py
@@ -1,10 +1,12 @@
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
@@ -18,6 +20,7 @@ from falyx.exceptions import (
)
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
@@ -133,15 +136,15 @@ async def test_command_runner_initialization(
assert runner.command == command_with_parser
assert runner.program == "test_program"
assert runner.command.arg_parser.program == "test_program"
- assert isinstance(runner.options, OptionsManager)
+ assert isinstance(runner.options_manager, OptionsManager)
assert isinstance(runner.runner_hooks, HookManager)
assert runner.console == falyx_console
- assert runner.command.options_manager == runner.options
- assert runner.command.arg_parser.options_manager == runner.options
- assert runner.command.options_manager == runner.options
- assert runner.executor.options == runner.options
+ assert runner.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.get("summary", namespace_name="execution") is None
+ 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
@@ -161,12 +164,12 @@ async def test_command_runner_initialization(
def test_command_runner_initialization_with_custom_options(command_with_parser):
custom_options = OptionsManager([("default", {"summary": True})])
- runner = CommandRunner(command_with_parser, options=custom_options)
- assert runner.options == custom_options
- assert runner.options.get("summary", namespace_name="default") is True
- assert runner.command.options_manager == runner.options
- assert runner.command.arg_parser.options_manager == runner.options
- assert runner.command.options_manager == runner.options
+ 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):
@@ -190,11 +193,11 @@ def test_command_runner_initialization_with_all_bad_components(command_with_pars
custom_hooks = "Not a HookManager"
with pytest.raises(
- NotAFalyxError, match="options must be an instance of OptionsManager"
+ NotAFalyxError, match="options_manager must be an instance of OptionsManager"
):
CommandRunner(
command_with_parser,
- options=custom_options,
+ options_manager=custom_options,
)
with pytest.raises(
@@ -247,9 +250,10 @@ async def test_command_runner_run_with_failing_action(command_with_failing_actio
@pytest.mark.asyncio
async def test_command_runner_debug_statement(command_with_parser, caplog):
- caplog.set_level("DEBUG")
+ 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
)
@@ -539,3 +543,126 @@ async def test_command_runner_run_error(command_with_parser):
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"