feat(core): clone commands and actions when binding runtimes

Add clone support across Action types and Command so commands can be safely
registered or runner-bound without mutating the original instances.

- clone BaseAction implementations across simple, composite, IO, prompt, file,
  HTTP, process, and signal actions
- bind cloned commands in Falyx.add_command_from_command() and CommandRunner
- preserve local never_prompt settings when cloning actions
- rename shared runtime state from options to options_manager for consistency
- seed root and execution option namespaces consistently
- apply scoped root and namespace option overrides during routing and dispatch
- improve namespace completion by delegating option suggestions to FalyxParser
- enrich missing-value errors and error hints
This commit is contained in:
2026-06-07 13:04:35 -04:00
parent 8db7a9e6dc
commit 40b41ac6f9
78 changed files with 9513 additions and 433 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)

View File

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

View File

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

View File

@@ -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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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())

View File

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

View File

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

View File

@@ -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",
):

View File

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

View File

@@ -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:

View File

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

View File

@@ -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}")

View File

@@ -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):

View File

@@ -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:

View File

@@ -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,6 +2352,13 @@ class Falyx:
kwargs,
execution_args,
)
route.namespace.options_manager.seed_missing(
route.namespace_defaults,
)
with route.namespace.options_manager.override_namespace(
route.namespace_overrides,
"default",
):
return await self._executor.execute(
command=route.command,
args=args,
@@ -2372,6 +2436,14 @@ class Falyx:
assert route is not None, "prepare_route should never return None."
route.namespace.options_manager.seed_missing(
route.root_defaults,
namespace_name="root",
)
with route.namespace.options_manager.override_namespace(
route.root_overrides,
namespace_name="root",
):
return await self._dispatch_route(
route=route,
args=args,
@@ -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,6 +2813,14 @@ class Falyx:
assert route is not None, "prepare_route should never return None."
try:
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,
@@ -2704,7 +2835,7 @@ class Falyx:
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:

View File

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

View File

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

View File

@@ -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())})"

View File

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

View File

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

View File

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

View File

@@ -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 "",
)

View File

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

41
falyx/parser/option.py Normal file
View File

@@ -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)

View File

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

View File

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

View File

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

View File

@@ -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.

View File

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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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))

View File

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

View File

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

View File

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

View File

@@ -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("<root><name>falyx</name></root>", 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("<root><name>falyx</root>", 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__)

View File

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

341
tests/test_context.py Normal file
View File

@@ -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 "<ExecutionContext 'Build' | OK | Duration: n/a" in text
assert "Result: ['ok']" in text
assert "ExecutionContext(name='Build', duration=n/a" in debug
assert "result=['ok']" in debug
def test_execution_context_str_and_repr_render_exception_with_duration() -> None:
context = make_execution_context(start_time=1.0, end_time=1.75)
context.exception = RuntimeError("failed")
text = str(context)
debug = repr(context)
assert "<ExecutionContext 'Build' | ERROR | Duration: 0.750s" in text
assert "Exception: failed" in text
assert "duration=0.750" in debug
assert "exception=RuntimeError('failed')" in debug
def test_shared_context_records_results_errors_and_share_values() -> 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 "<SequentialSharedContext 'Workflow'" in str(sequential)
assert "Results: ['a']" in str(sequential)
assert "<ConcurrentSharedContext 'Workflow'" in str(concurrent)
assert "Results: ['b']" in str(concurrent)
def test_invocation_context_menu_path_segment_operations_are_immutable() -> 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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)

View File

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

View File

@@ -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 "<command or namespace>" 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: <lambda>(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

View File

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

View File

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

226
tests/test_hook_manager.py Normal file
View File

@@ -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("<HookManager>")
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]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

489
tests/test_selection.py Normal file
View File

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