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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
169
falyx/command.py
169
falyx/command.py
@@ -191,7 +191,7 @@ class Command(BaseModel):
|
||||
spinner_type: str = "dots"
|
||||
spinner_style: Style | str = OneColors.CYAN
|
||||
spinner_speed: float = 1.0
|
||||
hooks: "HookManager" = Field(default_factory=HookManager)
|
||||
hooks: HookManager = Field(default_factory=HookManager)
|
||||
retry: bool = False
|
||||
retry_all: bool = False
|
||||
retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
|
||||
@@ -307,6 +307,7 @@ class Command(BaseModel):
|
||||
@field_validator("action", mode="before")
|
||||
@classmethod
|
||||
def _wrap_callable_as_async(cls, action: Any) -> Any:
|
||||
"""Ensure the action is an async callable or a BaseAction instance."""
|
||||
if isinstance(action, BaseAction):
|
||||
return action
|
||||
elif callable(action):
|
||||
@@ -314,6 +315,7 @@ class Command(BaseModel):
|
||||
raise TypeError("Action must be a callable or an instance of BaseAction")
|
||||
|
||||
def _get_argument_definitions(self) -> list[dict[str, Any]]:
|
||||
"""Retrieve the argument definitions for the command."""
|
||||
if self.arguments:
|
||||
return self.arguments
|
||||
elif callable(self.argument_config) and isinstance(
|
||||
@@ -382,6 +384,22 @@ class Command(BaseModel):
|
||||
if isinstance(self.action, BaseAction):
|
||||
self.action.set_options_manager(self.options_manager)
|
||||
|
||||
async def _handle_prompt_user(self) -> None:
|
||||
"""Handle user confirmation prompts based on command configuration and options."""
|
||||
action_never_prompt = None
|
||||
if isinstance(self.action, BaseAction):
|
||||
action_never_prompt = self.action.local_never_prompt
|
||||
if should_prompt_user(
|
||||
confirm=self.confirm,
|
||||
options=self.options_manager,
|
||||
action_never_prompt=action_never_prompt,
|
||||
):
|
||||
if self.preview_before_confirm:
|
||||
await self.preview()
|
||||
if not await confirm_async(self._confirmation_prompt):
|
||||
logger.info("[Command:%s] Cancelled by user.", self.key)
|
||||
raise CancelSignal(f"[Command:{self.key}] Cancelled by confirmation.")
|
||||
|
||||
async def __call__(self, *args, **kwargs) -> Any:
|
||||
"""Execute the command's underlying action with lifecycle management.
|
||||
|
||||
@@ -430,12 +448,7 @@ class Command(BaseModel):
|
||||
)
|
||||
self._context = context
|
||||
|
||||
if should_prompt_user(confirm=self.confirm, options=self.options_manager):
|
||||
if self.preview_before_confirm:
|
||||
await self.preview()
|
||||
if not await confirm_async(self._confirmation_prompt):
|
||||
logger.info("[Command:%s] Cancelled by user.", self.key)
|
||||
raise CancelSignal(f"[Command:{self.key}] Cancelled by confirmation.")
|
||||
await self._handle_prompt_user()
|
||||
|
||||
context.start_timer()
|
||||
|
||||
@@ -486,6 +499,18 @@ class Command(BaseModel):
|
||||
|
||||
return FormattedText(prompt)
|
||||
|
||||
def get_option(
|
||||
self,
|
||||
option_name: str,
|
||||
default: Any = None,
|
||||
*,
|
||||
namespace_name: str = "default",
|
||||
) -> Any:
|
||||
"""Resolve an option from the OptionsManager if present, else default."""
|
||||
if self.options_manager:
|
||||
return self.options_manager.get(option_name, default, namespace_name)
|
||||
return default
|
||||
|
||||
@property
|
||||
def primary_alias(self) -> str:
|
||||
"""Get the primary alias for the command, used in help displays."""
|
||||
@@ -557,6 +582,7 @@ class Command(BaseModel):
|
||||
)
|
||||
|
||||
def log_summary(self) -> None:
|
||||
"""Log a summary of the command execution if context is available."""
|
||||
if self._context:
|
||||
self._context.log_summary()
|
||||
|
||||
@@ -597,6 +623,7 @@ class Command(BaseModel):
|
||||
return False
|
||||
|
||||
async def preview(self) -> None:
|
||||
"""Preview the command execution."""
|
||||
label = f"[{OneColors.GREEN_b}]Command:[/] '{self.key}' — {self.description}"
|
||||
|
||||
if hasattr(self.action, "preview") and callable(self.action.preview):
|
||||
@@ -855,3 +882,131 @@ class Command(BaseModel):
|
||||
command.hooks.register(HookType.ON_TEARDOWN, spinner_teardown_hook)
|
||||
|
||||
return command
|
||||
|
||||
def clone_with_overrides(
|
||||
self,
|
||||
*,
|
||||
key: str | None = None,
|
||||
description: str | None = None,
|
||||
action: BaseAction | Callable[..., Any] | None = None,
|
||||
args: tuple | None = None,
|
||||
kwargs: dict[str, Any] | None = None,
|
||||
hidden: bool | None = None,
|
||||
aliases: list[str] | None = None,
|
||||
help_text: str | None = None,
|
||||
help_epilog: str | None = None,
|
||||
style: Style | str | None = None,
|
||||
confirm: bool | None = None,
|
||||
confirm_message: str | None = None,
|
||||
preview_before_confirm: bool | None = None,
|
||||
spinner: bool | None = None,
|
||||
spinner_message: str | None = None,
|
||||
spinner_type: str | None = None,
|
||||
spinner_style: Style | str | None = None,
|
||||
spinner_speed: float | None = None,
|
||||
hooks: HookManager | None = None,
|
||||
retry: bool | None = None,
|
||||
retry_all: bool | None = None,
|
||||
retry_policy: RetryPolicy | None = None,
|
||||
tags: list[str] | None = None,
|
||||
logging_hooks: bool | None = None,
|
||||
options_manager: OptionsManager | None = None,
|
||||
arg_parser: CommandArgumentParser | None = None,
|
||||
execution_options: list[ExecutionOption | str] | None = None,
|
||||
arguments: list[dict[str, Any]] | None = None,
|
||||
argument_config: Callable[[CommandArgumentParser], None] | None = None,
|
||||
custom_parser: ArgParserProtocol | None = None,
|
||||
custom_help: Callable[[], str | None] | None = None,
|
||||
custom_tldr: Callable[[], str | None] | None = None,
|
||||
custom_usage: Callable[[], str | None] | None = None,
|
||||
auto_args: bool | None = None,
|
||||
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
|
||||
simple_help_signature: bool | None = None,
|
||||
ignore_in_history: bool | None = None,
|
||||
program: str | None = None,
|
||||
) -> Command:
|
||||
"""Create a clone of the command with specified overrides."""
|
||||
if not arg_parser and self.arg_parser:
|
||||
arg_parser = self.arg_parser.clone_with_overrides(
|
||||
command_key=key or self.key,
|
||||
command_description=description or self.description,
|
||||
command_style=style or self.style,
|
||||
help_text=help_text or self.help_text,
|
||||
help_epilog=help_epilog or self.help_epilog,
|
||||
aliases=aliases if aliases is not None else self.aliases,
|
||||
program=program or self.program,
|
||||
options_manager=options_manager or self.options_manager,
|
||||
)
|
||||
if not hooks and self.hooks:
|
||||
hooks = self.hooks.copy()
|
||||
if not action and self.action:
|
||||
if isinstance(self.action, BaseAction):
|
||||
cloned_action: (
|
||||
BaseAction | Callable[..., Any] | Callable[..., Awaitable[Any]]
|
||||
) = self.action.clone()
|
||||
elif callable(self.action):
|
||||
cloned_action = self.action
|
||||
else:
|
||||
raise NotAFalyxError("Action must be a BaseAction or callable to clone.")
|
||||
return Command.build(
|
||||
key=key or self.key,
|
||||
description=description or self.description,
|
||||
action=action or cloned_action,
|
||||
args=args if args is not None else self.args,
|
||||
kwargs=kwargs if kwargs is not None else self.kwargs,
|
||||
hidden=hidden if hidden is not None else self.hidden,
|
||||
aliases=aliases if aliases is not None else self.aliases,
|
||||
help_text=help_text if help_text is not None else self.help_text,
|
||||
help_epilog=help_epilog if help_epilog is not None else self.help_epilog,
|
||||
style=style or self.style,
|
||||
confirm=confirm if confirm is not None else self.confirm,
|
||||
confirm_message=confirm_message or self.confirm_message,
|
||||
preview_before_confirm=(
|
||||
preview_before_confirm
|
||||
if preview_before_confirm is not None
|
||||
else self.preview_before_confirm
|
||||
),
|
||||
spinner=spinner if spinner is not None else self.spinner,
|
||||
spinner_message=spinner_message or self.spinner_message,
|
||||
spinner_type=spinner_type or self.spinner_type,
|
||||
spinner_style=spinner_style or self.spinner_style,
|
||||
spinner_speed=(
|
||||
spinner_speed if spinner_speed is not None else self.spinner_speed
|
||||
),
|
||||
hooks=hooks or self.hooks,
|
||||
retry=retry if retry is not None else self.retry,
|
||||
retry_all=retry_all if retry_all is not None else self.retry_all,
|
||||
retry_policy=retry_policy or self.retry_policy,
|
||||
tags=tags if tags is not None else self.tags,
|
||||
logging_hooks=(
|
||||
logging_hooks if logging_hooks is not None else self.logging_hooks
|
||||
),
|
||||
options_manager=options_manager or self.options_manager,
|
||||
arg_parser=arg_parser or self.arg_parser,
|
||||
execution_options=(
|
||||
execution_options
|
||||
if execution_options is not None
|
||||
else (list(self.execution_options) if self.execution_options else [])
|
||||
),
|
||||
arguments=arguments if arguments is not None else (self.arguments or []),
|
||||
argument_config=argument_config or self.argument_config,
|
||||
custom_parser=custom_parser or self.custom_parser,
|
||||
custom_help=custom_help or self.custom_help,
|
||||
custom_tldr=custom_tldr or self.custom_tldr,
|
||||
custom_usage=custom_usage or self.custom_usage,
|
||||
auto_args=auto_args if auto_args is not None else self.auto_args,
|
||||
arg_metadata=(
|
||||
arg_metadata if arg_metadata is not None else (self.arg_metadata or {})
|
||||
),
|
||||
simple_help_signature=(
|
||||
simple_help_signature
|
||||
if simple_help_signature is not None
|
||||
else self.simple_help_signature
|
||||
),
|
||||
ignore_in_history=(
|
||||
ignore_in_history
|
||||
if ignore_in_history is not None
|
||||
else self.ignore_in_history
|
||||
),
|
||||
program=program or self.program,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
289
falyx/falyx.py
289
falyx/falyx.py
@@ -64,11 +64,12 @@ import asyncio
|
||||
import logging
|
||||
import shlex
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from difflib import get_close_matches
|
||||
from functools import cached_property
|
||||
from pathlib import Path
|
||||
from random import choice
|
||||
from typing import Any, Callable
|
||||
from typing import Any
|
||||
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.application import get_app
|
||||
@@ -103,6 +104,7 @@ from falyx.exceptions import (
|
||||
CommandArgumentError,
|
||||
EntryNotFoundError,
|
||||
FalyxError,
|
||||
FalyxOptionError,
|
||||
InvalidActionError,
|
||||
InvalidHookError,
|
||||
NotAFalyxError,
|
||||
@@ -115,7 +117,8 @@ from falyx.logger import logger
|
||||
from falyx.mode import FalyxMode
|
||||
from falyx.namespace import FalyxNamespace
|
||||
from falyx.options_manager import OptionsManager
|
||||
from falyx.parser import CommandArgumentParser, FalyxParser, ParseResult
|
||||
from falyx.parser import CommandArgumentParser, FalyxParser, OptionAction
|
||||
from falyx.parser.option import Option
|
||||
from falyx.parser.parser_types import FalyxTLDRInput
|
||||
from falyx.prompt_utils import rich_text_to_prompt_text
|
||||
from falyx.protocols import ArgParserProtocol
|
||||
@@ -128,7 +131,6 @@ from falyx.validators import CommandValidator
|
||||
from falyx.version import __version__
|
||||
|
||||
|
||||
# TODO: better OptionsManager determination (assert same instance across a namespace)
|
||||
class Falyx:
|
||||
"""Primary controller for Falyx CLI applications.
|
||||
|
||||
@@ -206,7 +208,7 @@ class Falyx:
|
||||
builtins (dict[str, Command]): Registered built-in commands such as help,
|
||||
preview, and version.
|
||||
namespaces (dict[str, FalyxNamespace]): Registered nested namespaces.
|
||||
options (OptionsManager): Shared runtime option manager.
|
||||
options_manager (OptionsManager): Shared runtime option manager.
|
||||
hooks (HookManager): Application-level hook manager.
|
||||
console (Console): Rich console used for rendering output.
|
||||
key_bindings (KeyBindings): Prompt Toolkit key bindings for menu mode.
|
||||
@@ -254,7 +256,7 @@ class Falyx:
|
||||
force_confirm: bool = False,
|
||||
verbose: bool = False,
|
||||
debug_hooks: bool = False,
|
||||
options: OptionsManager | None = None,
|
||||
options_manager: OptionsManager | None = None,
|
||||
render_menu: Callable[[Falyx], None] | None = None,
|
||||
custom_table: Callable[[Falyx], Table] | Table | None = None,
|
||||
hide_menu_table: bool = False,
|
||||
@@ -328,7 +330,7 @@ class Falyx:
|
||||
runtime option.
|
||||
verbose (bool): Default session-level value for the `verbose` runtime option.
|
||||
debug_hooks (bool): Default session-level value for the `debug_hooks` runtime option.
|
||||
options (OptionsManager | None): Shared options manager for the application.
|
||||
options_manager (OptionsManager | None): Shared options manager for the application.
|
||||
If omitted, a new `OptionsManager` instance is created.
|
||||
render_menu (Callable[[Falyx], None] | None): Optional custom menu renderer
|
||||
used instead of the default table-based menu output.
|
||||
@@ -355,7 +357,7 @@ class Falyx:
|
||||
option from the root parser.
|
||||
|
||||
Raises:
|
||||
FalyxError: If the provided options object is invalid or other core runtime
|
||||
FalyxError: If the provided options_manager object is invalid or other core runtime
|
||||
configuration is inconsistent.
|
||||
|
||||
Notes:
|
||||
@@ -399,9 +401,9 @@ class Falyx:
|
||||
self.custom_table: Callable[[Falyx], Table] | Table | None = custom_table
|
||||
self._hide_menu_table: bool = hide_menu_table
|
||||
self.show_placeholder_menu: bool = show_placeholder_menu
|
||||
self._validate_options(options)
|
||||
self._validate_options_manager(options_manager)
|
||||
self._prompt_session: PromptSession | None = None
|
||||
self.options.set("mode", FalyxMode.COMMAND)
|
||||
self.options_manager.set("mode", FalyxMode.COMMAND)
|
||||
self.exit_command: Command = self._get_exit_command()
|
||||
self.history_command: Command | None = (
|
||||
self._get_history_command() if include_history_command else None
|
||||
@@ -419,9 +421,9 @@ class Falyx:
|
||||
self.default_to_menu: bool = default_to_menu
|
||||
self.simple_usage: bool = simple_usage
|
||||
self._register_default_builtins()
|
||||
self._register_options()
|
||||
self._register_runtime_options()
|
||||
self._executor = CommandExecutor(
|
||||
options=self.options,
|
||||
options_manager=self.options_manager,
|
||||
hooks=self.hooks,
|
||||
)
|
||||
self.disable_verbose_option: bool = disable_verbose_option
|
||||
@@ -429,6 +431,38 @@ class Falyx:
|
||||
self.disable_never_prompt_option: bool = disable_never_prompt_option
|
||||
self.parser: FalyxParser = FalyxParser(self)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f"Falyx(program='{self.program}', "
|
||||
f"title='{self.title}', "
|
||||
f"description='{self.description}')"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.__str__()
|
||||
|
||||
def add_option(
|
||||
self,
|
||||
*flags: str,
|
||||
action: str | OptionAction = "store",
|
||||
default: Any = None,
|
||||
type: Callable[[Any], Any] = str,
|
||||
choices: list[str] | None = None,
|
||||
help: str = "",
|
||||
dest: str | None = None,
|
||||
suggestions: list[str] | None = None,
|
||||
) -> Option:
|
||||
return self.parser.add_option(
|
||||
*flags,
|
||||
action=action,
|
||||
default=default,
|
||||
type=type,
|
||||
choices=choices,
|
||||
help=help,
|
||||
dest=dest,
|
||||
suggestions=suggestions,
|
||||
)
|
||||
|
||||
def add_tldr_example(
|
||||
self,
|
||||
*,
|
||||
@@ -487,7 +521,7 @@ class Falyx:
|
||||
program=self.program,
|
||||
program_style=self.program_style,
|
||||
typed_path=[],
|
||||
mode=self.options.get("mode"),
|
||||
mode=self.options_manager.get("mode"),
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -497,11 +531,11 @@ class Falyx:
|
||||
Returns:
|
||||
bool: `True` when the active mode is not `FalyxMode.MENU`.
|
||||
"""
|
||||
return self.options.get("mode") != FalyxMode.MENU
|
||||
return self.options_manager.get("mode") != FalyxMode.MENU
|
||||
|
||||
def _validate_options(
|
||||
def _validate_options_manager(
|
||||
self,
|
||||
options: OptionsManager | None = None,
|
||||
options_manager: OptionsManager | None = None,
|
||||
) -> None:
|
||||
"""Validate and install the shared options manager.
|
||||
|
||||
@@ -509,44 +543,47 @@ class Falyx:
|
||||
stored on the instance.
|
||||
|
||||
Args:
|
||||
options (OptionsManager | None): Optional options manager to reuse.
|
||||
options_manager (OptionsManager | None): Optional options manager to reuse.
|
||||
|
||||
Raises:
|
||||
NotAFalyxError: If `options` is provided but is not an `OptionsManager`.
|
||||
NotAFalyxError: If `options_manager` is provided but is not an `OptionsManager`.
|
||||
"""
|
||||
self.options: OptionsManager = options or OptionsManager()
|
||||
if not isinstance(self.options, OptionsManager):
|
||||
raise NotAFalyxError("options must be an instance of OptionsManager.")
|
||||
self.options_manager: OptionsManager = options_manager or OptionsManager()
|
||||
if not isinstance(self.options_manager, OptionsManager):
|
||||
raise NotAFalyxError("options_manager must be an instance of OptionsManager.")
|
||||
|
||||
def _register_options(self) -> None:
|
||||
def _register_runtime_options(self) -> None:
|
||||
"""Seed default application options and execution namespace values.
|
||||
|
||||
This method ensures that core runtime flags such as mode, prompt behavior,
|
||||
This method ensures that core runtime flags such as prompt behavior,
|
||||
menu visibility, and program display metadata exist in the shared options
|
||||
manager.
|
||||
"""
|
||||
self.options.from_mapping(values={}, namespace_name="execution")
|
||||
if not self.options_manager.get_namespace("root"):
|
||||
self.options_manager.from_mapping(values={}, namespace_name="root")
|
||||
if not self.options_manager.get_namespace("execution"):
|
||||
self.options_manager.from_mapping(values={}, namespace_name="execution")
|
||||
|
||||
if not self.options.get("never_prompt"):
|
||||
self.options.set("never_prompt", self._never_prompt)
|
||||
if not self.options_manager.has_option("never_prompt"):
|
||||
self.options_manager.set("never_prompt", self._never_prompt, "root")
|
||||
|
||||
if not self.options.get("force_confirm"):
|
||||
self.options.set("force_confirm", self._force_confirm)
|
||||
if not self.options_manager.has_option("force_confirm"):
|
||||
self.options_manager.set("force_confirm", self._force_confirm, "root")
|
||||
|
||||
if not self.options.get("verbose"):
|
||||
self.options.set("verbose", self._verbose)
|
||||
if not self.options_manager.has_option("verbose"):
|
||||
self.options_manager.set("verbose", self._verbose, "root")
|
||||
|
||||
if not self.options.get("debug_hooks"):
|
||||
self.options.set("debug_hooks", self._debug_hooks)
|
||||
if not self.options_manager.has_option("debug_hooks"):
|
||||
self.options_manager.set("debug_hooks", self._debug_hooks, "root")
|
||||
|
||||
if not self.options.get("hide_menu_table"):
|
||||
self.options.set("hide_menu_table", self._hide_menu_table)
|
||||
if not self.options_manager.has_option("hide_menu_table"):
|
||||
self.options_manager.set("hide_menu_table", self._hide_menu_table)
|
||||
|
||||
if not self.options.get("program"):
|
||||
self.options.set("program", self.program)
|
||||
if not self.options_manager.has_option("program"):
|
||||
self.options_manager.set("program", self.program)
|
||||
|
||||
if not self.options.get("program_style"):
|
||||
self.options.set("program_style", self.program_style)
|
||||
if not self.options_manager.has_option("program_style"):
|
||||
self.options_manager.set("program_style", self.program_style)
|
||||
|
||||
@property
|
||||
def completion_names(self) -> list[str]:
|
||||
@@ -670,7 +707,7 @@ class Falyx:
|
||||
style=OneColors.DARK_RED,
|
||||
simple_help_signature=True,
|
||||
ignore_in_history=True,
|
||||
options_manager=self.options,
|
||||
options_manager=self.options_manager,
|
||||
program=self.program,
|
||||
help_text="Exit the program.",
|
||||
)
|
||||
@@ -744,7 +781,7 @@ class Falyx:
|
||||
argument_config=add_history_arguments,
|
||||
help_text="View the execution history of commands.",
|
||||
ignore_in_history=True,
|
||||
options_manager=self.options,
|
||||
options_manager=self.options_manager,
|
||||
program=self.program,
|
||||
)
|
||||
|
||||
@@ -1313,7 +1350,7 @@ class Falyx:
|
||||
style=OneColors.LIGHT_YELLOW,
|
||||
argument_config=add_help_arguments,
|
||||
ignore_in_history=True,
|
||||
options_manager=self.options,
|
||||
options_manager=self.options_manager,
|
||||
program=self.program,
|
||||
)
|
||||
|
||||
@@ -1364,7 +1401,7 @@ class Falyx:
|
||||
aliases=["PREVIEW"],
|
||||
action=Action("Preview", self._preview),
|
||||
style=OneColors.GREEN,
|
||||
options_manager=self.options,
|
||||
options_manager=self.options_manager,
|
||||
program=self.program,
|
||||
help_text="Preview the execution of a command without running it.",
|
||||
argument_config=add_preview_argument,
|
||||
@@ -1388,7 +1425,7 @@ class Falyx:
|
||||
action=Action("Version", self._render_version),
|
||||
style=self.version_style,
|
||||
ignore_in_history=True,
|
||||
options_manager=self.options,
|
||||
options_manager=self.options_manager,
|
||||
program=self.program,
|
||||
help_text=f"Show the {self.program} version.",
|
||||
)
|
||||
@@ -1664,7 +1701,7 @@ class Falyx:
|
||||
confirm=confirm,
|
||||
confirm_message=confirm_message,
|
||||
ignore_in_history=True,
|
||||
options_manager=self.options,
|
||||
options_manager=self.options_manager,
|
||||
program=self.program,
|
||||
help_text=help_text,
|
||||
)
|
||||
@@ -1726,41 +1763,56 @@ class Falyx:
|
||||
help_text="Go back to the previous menu.",
|
||||
)
|
||||
|
||||
def add_commands(self, commands: list[Command] | list[dict]) -> None:
|
||||
def add_commands(self, commands: list[Command] | list[dict]) -> list[Command]:
|
||||
"""Register multiple commands from instances or config dictionaries.
|
||||
|
||||
Args:
|
||||
commands (list[Command] | list[dict]): Sequence of `Command` objects or
|
||||
`add_command()` keyword dictionaries.
|
||||
|
||||
Returns:
|
||||
list[Command]: List of registered `Command` instances.
|
||||
|
||||
Raises:
|
||||
FalyxError: If an element is neither a `Command` nor a configuration
|
||||
dictionary.
|
||||
"""
|
||||
added_commands = []
|
||||
for command in commands:
|
||||
if isinstance(command, dict):
|
||||
self.add_command(**command)
|
||||
added_commands.append(self.add_command(**command))
|
||||
elif isinstance(command, Command):
|
||||
self.add_command_from_command(command)
|
||||
added_commands.append(self.add_command_from_command(command))
|
||||
else:
|
||||
raise FalyxError(
|
||||
"command must be a dictionary or an instance of Command."
|
||||
)
|
||||
return added_commands
|
||||
|
||||
def add_command_from_command(self, command: Command) -> None:
|
||||
"""Register an already-built `Command` object.
|
||||
def add_command_from_command(self, command: Command) -> Command:
|
||||
"""Registers a clone of the provided command and returns the bound clone
|
||||
owned by this namespace.
|
||||
|
||||
Args:
|
||||
command (Command): Preconstructed command to add to this namespace.
|
||||
|
||||
Returns:
|
||||
Command: The newly registered clone of the command instance bound to
|
||||
this namespace.
|
||||
|
||||
Raises:
|
||||
FalyxError: If `command` is not a `Command`.
|
||||
"""
|
||||
if not isinstance(command, Command):
|
||||
raise FalyxError("command must be an instance of Command.")
|
||||
self._validate_command_aliases(command.key, command.aliases)
|
||||
self.commands[command.key] = command
|
||||
bound_command = command.clone_with_overrides(
|
||||
options_manager=self.options_manager,
|
||||
program=self.program,
|
||||
)
|
||||
self.commands[command.key] = bound_command
|
||||
_ = self._entry_map
|
||||
return bound_command
|
||||
|
||||
def add_command(
|
||||
self,
|
||||
@@ -1903,7 +1955,7 @@ class Falyx:
|
||||
auto_args=auto_args,
|
||||
arg_metadata=arg_metadata,
|
||||
simple_help_signature=simple_help_signature,
|
||||
options_manager=self.options,
|
||||
options_manager=self.options_manager,
|
||||
ignore_in_history=ignore_in_history,
|
||||
program=self.program,
|
||||
)
|
||||
@@ -2146,7 +2198,7 @@ class Falyx:
|
||||
program=self.program,
|
||||
program_style=self.program_style,
|
||||
typed_path=[],
|
||||
mode=mode or self.options.get("mode"),
|
||||
mode=mode or self.options_manager.get("mode"),
|
||||
is_preview=is_preview,
|
||||
)
|
||||
|
||||
@@ -2253,6 +2305,7 @@ class Falyx:
|
||||
EOFError: If execution receives an unexpected end of input and `wrap_errors`
|
||||
is `False`.
|
||||
"""
|
||||
route.namespace._apply_root_options()
|
||||
if route.is_preview:
|
||||
if route.kind is RouteKind.COMMAND and route.command:
|
||||
logger.info("preview command '%s' selected.", route.command.key)
|
||||
@@ -2286,6 +2339,10 @@ class Falyx:
|
||||
|
||||
if command is route.namespace.help_command:
|
||||
kwargs = kwargs or {}
|
||||
# pop the help command key from the typed path to avoid it being
|
||||
# treated as a real argument during help rendering
|
||||
route.context.typed_path.pop()
|
||||
route.context.segments.pop()
|
||||
kwargs["invocation_context"] = route.context
|
||||
|
||||
logger.debug(
|
||||
@@ -2295,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())})"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "",
|
||||
)
|
||||
|
||||
@@ -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
41
falyx/parser/option.py
Normal 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)
|
||||
44
falyx/parser/option_action.py
Normal file
44
falyx/parser/option_action.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
334
tests/test_actions/test_clone.py
Normal file
334
tests/test_actions/test_clone.py
Normal 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]
|
||||
)
|
||||
430
tests/test_actions/test_save_file_action.py
Normal file
430
tests/test_actions/test_save_file_action.py
Normal 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
|
||||
@@ -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"
|
||||
|
||||
598
tests/test_actions/test_selection_file_action.py
Normal file
598
tests/test_actions/test_selection_file_action.py
Normal 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__)
|
||||
@@ -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
341
tests/test_context.py
Normal 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"
|
||||
307
tests/test_execution_registry.py
Normal file
307
tests/test_execution_registry.py
Normal 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
|
||||
55
tests/test_falyx/test_builtin_root_options.py
Normal file
55
tests/test_falyx/test_builtin_root_options.py
Normal 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]
|
||||
138
tests/test_falyx/test_command_clone_contract.py
Normal file
138
tests/test_falyx/test_command_clone_contract.py
Normal 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
|
||||
197
tests/test_falyx/test_command_prompt_contract.py
Normal file
197
tests/test_falyx/test_command_prompt_contract.py
Normal 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"]
|
||||
121
tests/test_falyx/test_completion_contract.py
Normal file
121
tests/test_falyx/test_completion_contract.py
Normal 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
|
||||
120
tests/test_falyx/test_dispatch_contract.py
Normal file
120
tests/test_falyx/test_dispatch_contract.py
Normal 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"
|
||||
68
tests/test_falyx/test_exceptions.py
Normal file
68
tests/test_falyx/test_exceptions.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
856
tests/test_falyx/test_extra.py
Normal file
856
tests/test_falyx/test_extra.py
Normal 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
|
||||
@@ -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")
|
||||
|
||||
219
tests/test_falyx/test_options_manager_contract.py
Normal file
219
tests/test_falyx/test_options_manager_contract.py
Normal 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
|
||||
21
tests/test_falyx/test_prompt_contract.py
Normal file
21
tests/test_falyx/test_prompt_contract.py
Normal 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
|
||||
92
tests/test_falyx/test_routing_contract.py
Normal file
92
tests/test_falyx/test_routing_contract.py
Normal 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"
|
||||
@@ -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
|
||||
|
||||
15
tests/test_falyx/test_signals.py
Normal file
15
tests/test_falyx/test_signals.py
Normal 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
|
||||
900
tests/test_falyx_parser/test_falyx_parser.py
Normal file
900
tests/test_falyx_parser/test_falyx_parser.py
Normal 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
226
tests/test_hook_manager.py
Normal 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]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
459
tests/test_parsers/test_command_argument_parser_extra.py
Normal file
459
tests/test_parsers/test_command_argument_parser_extra.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
489
tests/test_selection.py
Normal 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"
|
||||
Reference in New Issue
Block a user