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

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

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

View File

@@ -206,3 +206,34 @@ class Action(BaseAction):
f"retry={self.retry_policy.enabled}, " f"retry={self.retry_policy.enabled}, "
f"rollback={self.rollback is not None})" f"rollback={self.rollback is not None})"
) )
def _copy_hooks_without_retry(self) -> HookManager:
"""Create a copy of the current hooks, excluding any retry handlers."""
new_hooks = HookManager()
for hook_type, hooks in self.hooks._hooks.items():
for hook in hooks:
owner = getattr(hook, "__self__", None)
if not isinstance(owner, RetryHandler):
new_hooks.register(hook_type, hook)
return new_hooks
def clone(self) -> Action:
"""Create a copy of this Action with the same configuration."""
new_action = Action(
name=self.name,
action=self._action,
rollback=self._rollback,
args=self.args,
kwargs=self.kwargs,
hooks=self._copy_hooks_without_retry(),
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
never_prompt=self.local_never_prompt,
retry=self.retry_policy.enabled,
retry_policy=self.retry_policy.model_copy(deep=True),
spinner_message=self.spinner_message,
spinner_type=self.spinner_type,
spinner_style=self.spinner_style,
spinner_speed=self.spinner_speed,
)
return new_action

View File

@@ -30,6 +30,8 @@ Example:
inject_last_result=True, inject_last_result=True,
) )
""" """
from __future__ import annotations
from typing import Any, Callable from typing import Any, Callable
from rich.tree import Tree 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"factory={self._factory.__name__ if hasattr(self._factory, '__name__') else type(self._factory).__name__}, "
f"args={self.args!r}, kwargs={self.kwargs!r})" f"args={self.args!r}, kwargs={self.kwargs!r})"
) )
def clone(self) -> ActionFactory:
"""Return a copy of this ActionFactory with the same configuration."""
return ActionFactory(
name=self.name,
factory=self._factory,
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
args=self.args,
kwargs=self.kwargs,
preview_args=self.preview_args,
preview_kwargs=self.preview_kwargs,
)

View File

@@ -244,3 +244,24 @@ class ActionGroup(BaseAction, ActionListMixin):
f"inject_last_result={self.inject_last_result}, " f"inject_last_result={self.inject_last_result}, "
f"inject_into={self.inject_into})" f"inject_into={self.inject_into})"
) )
def clone(self):
"""Return a copy of this ActionGroup with the same configuration."""
cloned_actions = [
action.clone() if isinstance(action, BaseAction) else action
for action in self.actions
]
return ActionGroup(
name=self.name,
actions=cloned_actions,
args=self.args,
kwargs=self.kwargs,
hooks=self.hooks.copy(),
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
never_prompt=self.local_never_prompt,
spinner_message=self.spinner_message,
spinner_type=self.spinner_type,
spinner_style=self.spinner_style,
spinner_speed=self.spinner_speed,
)

View File

@@ -7,7 +7,7 @@ maintaining a mutable list of named actions—such as adding, removing, or retri
actions by name—without duplicating logic across composite action types. 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 from falyx.action.base_action import BaseAction

View File

@@ -125,10 +125,16 @@ class BaseAction(ABC):
def set_shared_context(self, shared_context: SharedContext) -> None: def set_shared_context(self, shared_context: SharedContext) -> None:
self.shared_context = shared_context 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.""" """Resolve an option from the OptionsManager if present, else default."""
if self.options_manager: if self.options_manager:
return self.options_manager.get(option_name, default) return self.options_manager.get(option_name, default, namespace_name)
return default return default
@property @property
@@ -142,7 +148,12 @@ class BaseAction(ABC):
def never_prompt(self) -> bool: def never_prompt(self) -> bool:
if self._never_prompt is not None: if self._never_prompt is not None:
return self._never_prompt 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 @property
def spinner_manager(self): def spinner_manager(self):
@@ -181,3 +192,8 @@ class BaseAction(ABC):
def __repr__(self) -> str: def __repr__(self) -> str:
return str(self) return str(self)
@abstractmethod
def clone(self) -> BaseAction:
"""Return a copy of this action. Must be implemented by subclasses."""
return self

View File

@@ -318,3 +318,26 @@ class ChainedAction(BaseAction, ActionListMixin):
f"args={self.args!r}, kwargs={self.kwargs!r}, " f"args={self.args!r}, kwargs={self.kwargs!r}, "
f"auto_inject={self.auto_inject}, return_list={self.return_list})" f"auto_inject={self.auto_inject}, return_list={self.return_list})"
) )
def clone(self) -> ChainedAction:
"""Create a copy of this ChainedAction with the same configuration."""
cloned_actions = [
action.clone() if isinstance(action, BaseAction) else action
for action in self.actions
]
return ChainedAction(
name=self.name,
actions=cloned_actions,
args=self.args,
kwargs=self.kwargs,
hooks=self.hooks.copy(),
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
auto_inject=self.auto_inject,
return_list=self.return_list,
never_prompt=self.local_never_prompt,
spinner_message=self.spinner_message,
spinner_type=self.spinner_type,
spinner_style=self.spinner_style,
spinner_speed=self.spinner_speed,
)

View File

@@ -89,7 +89,7 @@ class ConfirmAction(BaseAction):
prompt_message: str = "Confirm?", prompt_message: str = "Confirm?",
confirm_type: ConfirmType | str = ConfirmType.YES_NO, confirm_type: ConfirmType | str = ConfirmType.YES_NO,
prompt_session: PromptSession | None = None, prompt_session: PromptSession | None = None,
never_prompt: bool = False, never_prompt: bool | None = False,
word: str = "CONFIRM", word: str = "CONFIRM",
return_last_result: bool = False, return_last_result: bool = False,
inject_last_result: bool = True, inject_last_result: bool = True,
@@ -267,3 +267,17 @@ class ConfirmAction(BaseAction):
f"ConfirmAction(name={self.name}, message={self.prompt_message}, " f"ConfirmAction(name={self.name}, message={self.prompt_message}, "
f"confirm_type={self.confirm_type}, return_last_result={self.return_last_result})" f"confirm_type={self.confirm_type}, return_last_result={self.return_last_result})"
) )
def clone(self) -> ConfirmAction:
"""Return a copy of this ConfirmAction with the same configuration."""
return ConfirmAction(
name=self.name,
prompt_message=self.prompt_message,
confirm_type=self.confirm_type,
prompt_session=self.prompt_session,
never_prompt=self.local_never_prompt,
word=self.word,
return_last_result=self.return_last_result,
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
)

View File

@@ -35,6 +35,8 @@ Example:
The `FallbackAction` ensures that even if `MaybeFetchRemoteAction` fails or returns The `FallbackAction` ensures that even if `MaybeFetchRemoteAction` fails or returns
None, `ProcessDataAction` still receives a usable input. None, `ProcessDataAction` still receives a usable input.
""" """
from __future__ import annotations
from functools import cached_property from functools import cached_property
from typing import Any from typing import Any
@@ -83,3 +85,7 @@ class FallbackAction(Action):
def __str__(self) -> str: def __str__(self) -> str:
return f"FallbackAction(fallback={self.fallback!r})" return f"FallbackAction(fallback={self.fallback!r})"
def clone(self) -> FallbackAction:
"""Return a copy of this FallbackAction with the same fallback value."""
return FallbackAction(fallback=self.fallback)

View File

@@ -7,6 +7,9 @@ Features:
- Retry integration and last_result injection - Retry integration and last_result injection
- Clean resource teardown using hooks - Clean resource teardown using hooks
""" """
from __future__ import annotations
from copy import deepcopy
from typing import Any from typing import Any
import aiohttp import aiohttp
@@ -80,6 +83,7 @@ class HTTPAction(Action):
spinner_type: str = "dots", spinner_type: str = "dots",
spinner_style: str = OneColors.CYAN, spinner_style: str = OneColors.CYAN,
spinner_speed: float = 1.0, spinner_speed: float = 1.0,
never_prompt: bool | None = None,
): ):
self.method = method.upper() self.method = method.upper()
self.url = url self.url = url
@@ -103,6 +107,7 @@ class HTTPAction(Action):
spinner_type=spinner_type, spinner_type=spinner_type,
spinner_style=spinner_style, spinner_style=spinner_style,
spinner_speed=spinner_speed, spinner_speed=spinner_speed,
never_prompt=never_prompt,
) )
async def _request(self, *_, **__) -> dict[str, Any]: 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"data={self.data!r}, retry={self.retry_policy.enabled}, "
f"inject_last_result={self.inject_last_result})" f"inject_last_result={self.inject_last_result})"
) )
def clone(self) -> HTTPAction:
"""Return a copy of this HTTPAction with the same configuration."""
return HTTPAction(
name=self.name,
method=self.method,
url=self.url,
headers=self.headers.copy() if self.headers else None,
params=self.params.copy() if self.params else None,
json=deepcopy(self.json),
data=self.data,
hooks=self._copy_hooks_without_retry(),
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
retry=self.retry_policy.enabled,
retry_policy=self.retry_policy.model_copy(deep=True),
spinner=False,
spinner_message=self.spinner_message,
spinner_type=self.spinner_type,
spinner_style=self.spinner_style,
spinner_speed=self.spinner_speed,
never_prompt=self.local_never_prompt,
)

View File

@@ -14,6 +14,8 @@ Features:
Common usage includes shell-like filters, input transformers, or any tool that Common usage includes shell-like filters, input transformers, or any tool that
needs to consume input from another process or pipeline. needs to consume input from another process or pipeline.
""" """
from __future__ import annotations
import asyncio import asyncio
import sys import sys
from typing import Any, Callable from typing import Any, Callable
@@ -168,3 +170,12 @@ class BaseIOAction(BaseAction):
parent.add("".join(label)) parent.add("".join(label))
else: else:
self.console.print(Tree("".join(label))) self.console.print(Tree("".join(label)))
def clone(self) -> BaseIOAction:
"""Create a copy of this BaseIOAction with the same configuration."""
return self.__class__(
name=self.name,
hooks=self.hooks.copy(),
mode=self.mode,
inject_last_result=self.inject_last_result,
)

View File

@@ -76,3 +76,7 @@ class LiteralInputAction(Action):
def __str__(self) -> str: def __str__(self) -> str:
return f"LiteralInputAction(value={self.value!r})" return f"LiteralInputAction(value={self.value!r})"
def clone(self) -> LiteralInputAction:
"""Create a copy of this LiteralInputAction with the same value."""
return LiteralInputAction(self.value)

View File

@@ -35,6 +35,8 @@ This module is a foundational building block for file-driven CLI workflows in Fa
It is often paired with `SaveFileAction`, `SelectionAction`, or `ConfirmAction` for It is often paired with `SaveFileAction`, `SelectionAction`, or `ConfirmAction` for
robust and interactive pipelines. robust and interactive pipelines.
""" """
from __future__ import annotations
import csv import csv
import json import json
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
@@ -261,3 +263,14 @@ class LoadFileAction(BaseAction):
def __str__(self) -> str: def __str__(self) -> str:
return f"LoadFileAction(file_path={self.file_path}, file_type={self.file_type})" return f"LoadFileAction(file_path={self.file_path}, file_type={self.file_type})"
def clone(self) -> LoadFileAction:
"""Create a copy of this LoadFileAction with the same configuration."""
return LoadFileAction(
name=self.name,
file_path=self.file_path,
file_type=self.file_type,
encoding=self.encoding,
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
)

View File

@@ -36,6 +36,8 @@ Example:
This module is ideal for enabling structured, discoverable, and declarative This module is ideal for enabling structured, discoverable, and declarative
menus in both interactive and programmatic CLI automation. menus in both interactive and programmatic CLI automation.
""" """
from __future__ import annotations
from typing import Any from typing import Any
from prompt_toolkit import PromptSession from prompt_toolkit import PromptSession
@@ -119,7 +121,7 @@ class MenuAction(BaseAction):
inject_last_result: bool = False, inject_last_result: bool = False,
inject_into: str = "last_result", inject_into: str = "last_result",
prompt_session: PromptSession | None = None, prompt_session: PromptSession | None = None,
never_prompt: bool = False, never_prompt: bool | None = False,
include_reserved: bool = True, include_reserved: bool = True,
show_table: bool = True, show_table: bool = True,
custom_table: Table | None = None, custom_table: Table | None = None,
@@ -245,3 +247,21 @@ class MenuAction(BaseAction):
f"include_reserved={self.include_reserved}, " f"include_reserved={self.include_reserved}, "
f"prompt={'off' if self.never_prompt else 'on'})" f"prompt={'off' if self.never_prompt else 'on'})"
) )
def clone(self) -> MenuAction:
"""Create a copy of this MenuAction with the same configuration."""
return MenuAction(
name=self.name,
menu_options=self.menu_options.copy(),
title=self.title,
columns=self.columns,
prompt_message=self.prompt_message,
default_selection=self.default_selection,
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
prompt_session=self.prompt_session,
never_prompt=self.local_never_prompt,
include_reserved=self.include_reserved,
show_table=self.show_table,
custom_table=self.custom_table,
)

View File

@@ -177,3 +177,21 @@ class ProcessAction(BaseAction):
f"action={getattr(self.action, '__name__', repr(self.action))}, " f"action={getattr(self.action, '__name__', repr(self.action))}, "
f"args={self.args!r}, kwargs={self.kwargs!r})" f"args={self.args!r}, kwargs={self.kwargs!r})"
) )
def clone(self) -> ProcessAction:
"""Create a copy of this ProcessAction with the same configuration."""
return ProcessAction(
name=self.name,
action=self.action,
args=self.args,
kwargs=self.kwargs,
hooks=self.hooks.copy(),
executor=None,
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
never_prompt=self.local_never_prompt,
spinner_message=self.spinner_message,
spinner_type=self.spinner_type,
spinner_style=self.spinner_style,
spinner_speed=self.spinner_speed,
)

View File

@@ -58,6 +58,14 @@ class ProcessTask:
if not callable(self.task): if not callable(self.task):
raise TypeError(f"Expected a callable task, got {type(self.task).__name__}") 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): class ProcessPoolAction(BaseAction):
"""Executes a set of independent tasks in parallel using a process pool. """Executes a set of independent tasks in parallel using a process pool.
@@ -230,3 +238,15 @@ class ProcessPoolAction(BaseAction):
f"inject_last_result={self.inject_last_result}, " f"inject_last_result={self.inject_last_result}, "
f"inject_into={self.inject_into!r})" f"inject_into={self.inject_into!r})"
) )
def clone(self) -> ProcessPoolAction:
"""Create a copy of this ProcessPoolAction with the same configuration."""
cloned = ProcessPoolAction(
name=self.name,
actions=[action.copy() for action in self.actions],
hooks=self.hooks.copy(),
executor=None,
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
)
return cloned

View File

@@ -10,6 +10,8 @@ or contextual user input flows.
Key Components: Key Components:
- PromptMenuAction: Inline prompt-driven menu runner - PromptMenuAction: Inline prompt-driven menu runner
""" """
from __future__ import annotations
from typing import Any from typing import Any
from prompt_toolkit import PromptSession from prompt_toolkit import PromptSession
@@ -187,3 +189,17 @@ class PromptMenuAction(BaseAction):
f"include_reserved={self.include_reserved}, " f"include_reserved={self.include_reserved}, "
f"prompt={'off' if self.never_prompt else 'on'})" f"prompt={'off' if self.never_prompt else 'on'})"
) )
def clone(self) -> PromptMenuAction:
"""Create a copy of this PromptMenuAction with the same configuration."""
return PromptMenuAction(
name=self.name,
menu_options=self.menu_options.copy(),
prompt_message=self.prompt_message,
default_selection=self.default_selection,
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
prompt_session=self.prompt_session,
never_prompt=self.never_prompt,
include_reserved=self.include_reserved,
)

View File

@@ -19,6 +19,8 @@ Common use cases:
- Logging artifacts from batch pipelines - Logging artifacts from batch pipelines
- Exporting config or user input to JSON/YAML for reuse - Exporting config or user input to JSON/YAML for reuse
""" """
from __future__ import annotations
import csv import csv
import json import json
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
@@ -89,7 +91,7 @@ class SaveFileAction(BaseAction):
def __init__( def __init__(
self, self,
name: str, name: str,
file_path: str, file_path: str | Path | None,
file_type: FileType | str = FileType.TEXT, file_type: FileType | str = FileType.TEXT,
mode: Literal["w", "a"] = "w", mode: Literal["w", "a"] = "w",
encoding: str = "UTF-8", encoding: str = "UTF-8",
@@ -98,6 +100,7 @@ class SaveFileAction(BaseAction):
create_dirs: bool = True, create_dirs: bool = True,
inject_last_result: bool = False, inject_last_result: bool = False,
inject_into: str = "data", inject_into: str = "data",
never_prompt: bool | None = False,
): ):
"""SaveFileAction allows saving data to a file. """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. 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_last_result (bool): Whether to inject result from previous action.
inject_into (str): Kwarg name to inject the last result as. inject_into (str): Kwarg name to inject the last result as.
never_prompt (bool | None): Whether to never prompt for input.
""" """
super().__init__( 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_path = self._coerce_file_path(file_path)
self._file_type = FileType(file_type) self._file_type = FileType(file_type)
@@ -291,3 +298,19 @@ class SaveFileAction(BaseAction):
def __str__(self) -> str: def __str__(self) -> str:
return f"SaveFileAction(file_path={self.file_path}, file_type={self.file_type})" return f"SaveFileAction(file_path={self.file_path}, file_type={self.file_type})"
def clone(self) -> SaveFileAction:
"""Create a copy of this SaveFileAction with the same configuration."""
return SaveFileAction(
name=self.name,
file_path=self.file_path,
file_type=self.file_type,
mode=self.mode,
encoding=self.encoding,
data=self.data,
overwrite=self.overwrite,
create_dirs=self.create_dirs,
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
never_prompt=self.local_never_prompt,
)

View File

@@ -113,8 +113,9 @@ class SelectFileAction(BaseAction):
separator: str = ",", separator: str = ",",
allow_duplicates: bool = False, allow_duplicates: bool = False,
prompt_session: PromptSession | None = None, 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.directory = Path(directory).resolve()
self.title = title self.title = title
self.columns = columns self.columns = columns
@@ -183,6 +184,9 @@ class SelectFileAction(BaseAction):
raise ValueError(f"Unsupported return type: {self.return_type}") raise ValueError(f"Unsupported return type: {self.return_type}")
except Exception as error: except Exception as error:
logger.error("Failed to parse %s: %s", file.name, 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 return value
def _find_cancel_key(self, options) -> str: 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"SelectFileAction(name={self.name!r}, dir={str(self.directory)!r}, "
f"suffix_filter={self.suffix_filter!r}, return_type={self.return_type})" f"suffix_filter={self.suffix_filter!r}, return_type={self.return_type})"
) )
def clone(self) -> SelectFileAction:
"""Create a copy of this SelectFileAction with the same configuration."""
return SelectFileAction(
name=self.name,
directory=self.directory,
title=self.title,
columns=self.columns,
prompt_message=self.prompt_message,
style=self.style,
suffix_filter=self.suffix_filter,
return_type=self.return_type,
encoding=self.encoding,
number_selections=self.number_selections,
separator=self.separator,
allow_duplicates=self.allow_duplicates,
prompt_session=self.prompt_session,
never_prompt=self.local_never_prompt,
)

View File

@@ -30,6 +30,8 @@ Example:
This module is foundational to creating expressive, user-centered CLI experiences This module is foundational to creating expressive, user-centered CLI experiences
within Falyx while preserving reproducibility and automation friendliness. within Falyx while preserving reproducibility and automation friendliness.
""" """
from __future__ import annotations
from typing import Any from typing import Any
from prompt_toolkit import PromptSession from prompt_toolkit import PromptSession
@@ -138,7 +140,7 @@ class SelectionAction(BaseAction):
inject_into: str = "last_result", inject_into: str = "last_result",
return_type: SelectionReturnType | str = "value", return_type: SelectionReturnType | str = "value",
prompt_session: PromptSession | None = None, prompt_session: PromptSession | None = None,
never_prompt: bool = False, never_prompt: bool | None = False,
show_table: bool = True, show_table: bool = True,
): ):
super().__init__( super().__init__(
@@ -556,3 +558,23 @@ class SelectionAction(BaseAction):
f"return_type={self.return_type!r}, " f"return_type={self.return_type!r}, "
f"prompt={'off' if self.never_prompt else 'on'})" f"prompt={'off' if self.never_prompt else 'on'})"
) )
def clone(self) -> SelectionAction:
"""Create a copy of this SelectionAction with the same configuration."""
return SelectionAction(
name=self.name,
selections=self.selections.copy(),
title=self.title,
columns=self.columns,
prompt_message=self.prompt_message,
default_selection=self.default_selection,
number_selections=self.number_selections,
separator=self.separator,
allow_duplicates=self.allow_duplicates,
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
return_type=self.return_type,
prompt_session=self.prompt_session,
never_prompt=self.local_never_prompt,
show_table=self.show_table,
)

View File

@@ -101,3 +101,15 @@ class ShellAction(BaseIOAction):
f"ShellAction(name={self.name!r}, command_template={self.command_template!r}," f"ShellAction(name={self.name!r}, command_template={self.command_template!r},"
f" safe_mode={self.safe_mode})" f" safe_mode={self.safe_mode})"
) )
def clone(self) -> ShellAction:
"""Create a copy of this ShellAction with the same configuration."""
return ShellAction(
name=self.name,
command_template=self.command_template,
safe_mode=self.safe_mode,
mode=self.mode,
hooks=self.hooks.copy(),
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
)

View File

@@ -23,6 +23,8 @@ Use Cases:
Example: Example:
SignalAction("ExitApp", QuitSignal(), hooks=my_hook_manager) SignalAction("ExitApp", QuitSignal(), hooks=my_hook_manager)
""" """
from __future__ import annotations
from rich.tree import Tree from rich.tree import Tree
from falyx.action.action import Action from falyx.action.action import Action
@@ -90,3 +92,7 @@ class SignalAction(Action):
tree = parent.add(label) if parent else Tree(label) tree = parent.add(label) if parent else Tree(label)
if not parent: if not parent:
self.console.print(tree) self.console.print(tree)
def clone(self) -> SignalAction:
"""Creates a copy of this SignalAction with the same configuration."""
return SignalAction(name=self.name, signal=self.signal, hooks=self.hooks.copy())

View File

@@ -25,6 +25,8 @@ Example:
validator=Validator.from_callable(lambda s: len(s) > 0), validator=Validator.from_callable(lambda s: len(s) > 0),
) )
""" """
from __future__ import annotations
from prompt_toolkit import PromptSession from prompt_toolkit import PromptSession
from prompt_toolkit.validation import Validator from prompt_toolkit.validation import Validator
from rich.tree import Tree from rich.tree import Tree
@@ -132,3 +134,15 @@ class UserInputAction(BaseAction):
def __str__(self): def __str__(self):
return f"UserInputAction(name={self.name!r}, prompt={self.prompt!r})" return f"UserInputAction(name={self.name!r}, prompt={self.prompt!r})"
def clone(self) -> UserInputAction:
"""Creates a copy of this UserInputAction with the same configuration."""
return UserInputAction(
name=self.name,
prompt_message=self.prompt_message,
default_text=self.default_text,
multiline=self.multiline,
validator=self.validator,
prompt_session=self.prompt_session,
inject_last_result=self.inject_last_result,
)

View File

@@ -191,7 +191,7 @@ class Command(BaseModel):
spinner_type: str = "dots" spinner_type: str = "dots"
spinner_style: Style | str = OneColors.CYAN spinner_style: Style | str = OneColors.CYAN
spinner_speed: float = 1.0 spinner_speed: float = 1.0
hooks: "HookManager" = Field(default_factory=HookManager) hooks: HookManager = Field(default_factory=HookManager)
retry: bool = False retry: bool = False
retry_all: bool = False retry_all: bool = False
retry_policy: RetryPolicy = Field(default_factory=RetryPolicy) retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
@@ -307,6 +307,7 @@ class Command(BaseModel):
@field_validator("action", mode="before") @field_validator("action", mode="before")
@classmethod @classmethod
def _wrap_callable_as_async(cls, action: Any) -> Any: def _wrap_callable_as_async(cls, action: Any) -> Any:
"""Ensure the action is an async callable or a BaseAction instance."""
if isinstance(action, BaseAction): if isinstance(action, BaseAction):
return action return action
elif callable(action): elif callable(action):
@@ -314,6 +315,7 @@ class Command(BaseModel):
raise TypeError("Action must be a callable or an instance of BaseAction") raise TypeError("Action must be a callable or an instance of BaseAction")
def _get_argument_definitions(self) -> list[dict[str, Any]]: def _get_argument_definitions(self) -> list[dict[str, Any]]:
"""Retrieve the argument definitions for the command."""
if self.arguments: if self.arguments:
return self.arguments return self.arguments
elif callable(self.argument_config) and isinstance( elif callable(self.argument_config) and isinstance(
@@ -382,6 +384,22 @@ class Command(BaseModel):
if isinstance(self.action, BaseAction): if isinstance(self.action, BaseAction):
self.action.set_options_manager(self.options_manager) 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: async def __call__(self, *args, **kwargs) -> Any:
"""Execute the command's underlying action with lifecycle management. """Execute the command's underlying action with lifecycle management.
@@ -430,12 +448,7 @@ class Command(BaseModel):
) )
self._context = context self._context = context
if should_prompt_user(confirm=self.confirm, options=self.options_manager): await self._handle_prompt_user()
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.")
context.start_timer() context.start_timer()
@@ -486,6 +499,18 @@ class Command(BaseModel):
return FormattedText(prompt) 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 @property
def primary_alias(self) -> str: def primary_alias(self) -> str:
"""Get the primary alias for the command, used in help displays.""" """Get the primary alias for the command, used in help displays."""
@@ -557,6 +582,7 @@ class Command(BaseModel):
) )
def log_summary(self) -> None: def log_summary(self) -> None:
"""Log a summary of the command execution if context is available."""
if self._context: if self._context:
self._context.log_summary() self._context.log_summary()
@@ -597,6 +623,7 @@ class Command(BaseModel):
return False return False
async def preview(self) -> None: async def preview(self) -> None:
"""Preview the command execution."""
label = f"[{OneColors.GREEN_b}]Command:[/] '{self.key}'{self.description}" label = f"[{OneColors.GREEN_b}]Command:[/] '{self.key}'{self.description}"
if hasattr(self.action, "preview") and callable(self.action.preview): 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) command.hooks.register(HookType.ON_TEARDOWN, spinner_teardown_hook)
return command return command
def clone_with_overrides(
self,
*,
key: str | None = None,
description: str | None = None,
action: BaseAction | Callable[..., Any] | None = None,
args: tuple | None = None,
kwargs: dict[str, Any] | None = None,
hidden: bool | None = None,
aliases: list[str] | None = None,
help_text: str | None = None,
help_epilog: str | None = None,
style: Style | str | None = None,
confirm: bool | None = None,
confirm_message: str | None = None,
preview_before_confirm: bool | None = None,
spinner: bool | None = None,
spinner_message: str | None = None,
spinner_type: str | None = None,
spinner_style: Style | str | None = None,
spinner_speed: float | None = None,
hooks: HookManager | None = None,
retry: bool | None = None,
retry_all: bool | None = None,
retry_policy: RetryPolicy | None = None,
tags: list[str] | None = None,
logging_hooks: bool | None = None,
options_manager: OptionsManager | None = None,
arg_parser: CommandArgumentParser | None = None,
execution_options: list[ExecutionOption | str] | None = None,
arguments: list[dict[str, Any]] | None = None,
argument_config: Callable[[CommandArgumentParser], None] | None = None,
custom_parser: ArgParserProtocol | None = None,
custom_help: Callable[[], str | None] | None = None,
custom_tldr: Callable[[], str | None] | None = None,
custom_usage: Callable[[], str | None] | None = None,
auto_args: bool | None = None,
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
simple_help_signature: bool | None = None,
ignore_in_history: bool | None = None,
program: str | None = None,
) -> Command:
"""Create a clone of the command with specified overrides."""
if not arg_parser and self.arg_parser:
arg_parser = self.arg_parser.clone_with_overrides(
command_key=key or self.key,
command_description=description or self.description,
command_style=style or self.style,
help_text=help_text or self.help_text,
help_epilog=help_epilog or self.help_epilog,
aliases=aliases if aliases is not None else self.aliases,
program=program or self.program,
options_manager=options_manager or self.options_manager,
)
if not hooks and self.hooks:
hooks = self.hooks.copy()
if not action and self.action:
if isinstance(self.action, BaseAction):
cloned_action: (
BaseAction | Callable[..., Any] | Callable[..., Awaitable[Any]]
) = self.action.clone()
elif callable(self.action):
cloned_action = self.action
else:
raise NotAFalyxError("Action must be a BaseAction or callable to clone.")
return Command.build(
key=key or self.key,
description=description or self.description,
action=action or cloned_action,
args=args if args is not None else self.args,
kwargs=kwargs if kwargs is not None else self.kwargs,
hidden=hidden if hidden is not None else self.hidden,
aliases=aliases if aliases is not None else self.aliases,
help_text=help_text if help_text is not None else self.help_text,
help_epilog=help_epilog if help_epilog is not None else self.help_epilog,
style=style or self.style,
confirm=confirm if confirm is not None else self.confirm,
confirm_message=confirm_message or self.confirm_message,
preview_before_confirm=(
preview_before_confirm
if preview_before_confirm is not None
else self.preview_before_confirm
),
spinner=spinner if spinner is not None else self.spinner,
spinner_message=spinner_message or self.spinner_message,
spinner_type=spinner_type or self.spinner_type,
spinner_style=spinner_style or self.spinner_style,
spinner_speed=(
spinner_speed if spinner_speed is not None else self.spinner_speed
),
hooks=hooks or self.hooks,
retry=retry if retry is not None else self.retry,
retry_all=retry_all if retry_all is not None else self.retry_all,
retry_policy=retry_policy or self.retry_policy,
tags=tags if tags is not None else self.tags,
logging_hooks=(
logging_hooks if logging_hooks is not None else self.logging_hooks
),
options_manager=options_manager or self.options_manager,
arg_parser=arg_parser or self.arg_parser,
execution_options=(
execution_options
if execution_options is not None
else (list(self.execution_options) if self.execution_options else [])
),
arguments=arguments if arguments is not None else (self.arguments or []),
argument_config=argument_config or self.argument_config,
custom_parser=custom_parser or self.custom_parser,
custom_help=custom_help or self.custom_help,
custom_tldr=custom_tldr or self.custom_tldr,
custom_usage=custom_usage or self.custom_usage,
auto_args=auto_args if auto_args is not None else self.auto_args,
arg_metadata=(
arg_metadata if arg_metadata is not None else (self.arg_metadata or {})
),
simple_help_signature=(
simple_help_signature
if simple_help_signature is not None
else self.simple_help_signature
),
ignore_in_history=(
ignore_in_history
if ignore_in_history is not None
else self.ignore_in_history
),
program=program or self.program,
)

View File

@@ -82,7 +82,7 @@ class CommandExecutor:
- Emit optional execution summaries - Emit optional execution summaries
Attributes: Attributes:
options (OptionsManager): Shared options manager used to apply scoped options_manager (OptionsManager): Shared options manager used to apply scoped
execution overrides. execution overrides.
hooks (HookManager): Hook manager for executor-level lifecycle hooks. hooks (HookManager): Hook manager for executor-level lifecycle hooks.
""" """
@@ -90,10 +90,10 @@ class CommandExecutor:
def __init__( def __init__(
self, self,
*, *,
options: OptionsManager, options_manager: OptionsManager,
hooks: HookManager, hooks: HookManager,
) -> None: ) -> None:
self.options = options self.options_manager = options_manager
self.hooks = hooks self.hooks = hooks
def _debug_hooks(self, command: Command) -> None: def _debug_hooks(self, command: Command) -> None:
@@ -271,7 +271,7 @@ class CommandExecutor:
try: try:
await self.hooks.trigger(HookType.BEFORE, context) await self.hooks.trigger(HookType.BEFORE, context)
with self.options.override_namespace( with self.options_manager.override_namespace(
overrides=overrides, overrides=overrides,
namespace_name="execution", namespace_name="execution",
): ):

View File

@@ -92,7 +92,7 @@ class CommandRunner:
Attributes: Attributes:
command (Command): The command executed by this runner. command (Command): The command executed by this runner.
program (str): Program name used in CLI usage text and help output. 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. parser, and executor.
runner_hooks (HookManager): Executor-level hooks used during execution. runner_hooks (HookManager): Executor-level hooks used during execution.
console (Console): Rich console used for user-facing output. console (Console): Rich console used for user-facing output.
@@ -105,7 +105,7 @@ class CommandRunner:
command: Command, command: Command,
*, *,
program: str | None = None, program: str | None = None,
options: OptionsManager | None = None, options_manager: OptionsManager | None = None,
runner_hooks: HookManager | None = None, runner_hooks: HookManager | None = None,
console: Console | None = None, console: Console | None = None,
) -> None: ) -> None:
@@ -120,7 +120,7 @@ class CommandRunner:
program (str | None): Program name used in CLI usage text, invocation-path 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 rendering, and built-in help output. If `None`, an empty program name is
used. used.
options (OptionsManager | None): Optional shared options manager. If options_manager (OptionsManager | None): Optional shared options manager. If
omitted, a new `OptionsManager` is created. omitted, a new `OptionsManager` is created.
runner_hooks (HookManager | None): Optional executor-level hook manager. If runner_hooks (HookManager | None): Optional executor-level hook manager. If
omitted, a new `HookManager` is created. omitted, a new `HookManager` is created.
@@ -129,23 +129,26 @@ class CommandRunner:
""" """
self.command = command self.command = command
self.program = program or "" 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.runner_hooks = self._get_hooks(runner_hooks)
self.console = self._get_console(console) self.console = self._get_console(console)
self.error_console = error_console self.error_console = error_console
self.command.options_manager = self.options self.command.options_manager = self.options_manager
if program: if program:
self.command.program = program self.command.program = program
if isinstance(self.command.arg_parser, CommandArgumentParser): 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 self.command.arg_parser.is_runner_mode = True
if program: if program:
self.command.arg_parser.program = program self.command.arg_parser.program = program
self.executor = CommandExecutor( self.executor = CommandExecutor(
options=self.options, options_manager=self.options_manager,
hooks=self.runner_hooks, 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: def _get_console(self, console) -> Console:
if console is None: if console is None:
@@ -155,13 +158,18 @@ class CommandRunner:
else: else:
raise NotAFalyxError("console must be an instance of rich.Console or None.") raise NotAFalyxError("console must be an instance of rich.Console or None.")
def _get_options(self, options) -> OptionsManager: def _get_options_manager(
if options is None: self,
options_manager: OptionsManager | None,
) -> OptionsManager:
if options_manager is None:
return OptionsManager() return OptionsManager()
elif isinstance(options, OptionsManager): elif isinstance(options_manager, OptionsManager):
return options return options_manager
else: 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: def _get_hooks(self, hooks) -> HookManager:
if hooks is None: if hooks is None:
@@ -295,7 +303,7 @@ class CommandRunner:
*, *,
program: str | None = None, program: str | None = None,
runner_hooks: HookManager | None = None, runner_hooks: HookManager | None = None,
options: OptionsManager | None = None, options_manager: OptionsManager | None = None,
console: Console | None = None, console: Console | None = None,
) -> CommandRunner: ) -> CommandRunner:
"""Create a `CommandRunner` from an existing `Command` instance. """Create a `CommandRunner` from an existing `Command` instance.
@@ -311,7 +319,7 @@ class CommandRunner:
used. used.
runner_hooks (HookManager | None): Optional executor-level hook manager runner_hooks (HookManager | None): Optional executor-level hook manager
for the runner. 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. console (Console | None): Optional Rich console for output.
Returns: Returns:
@@ -325,10 +333,14 @@ class CommandRunner:
raise NotAFalyxError("command must be an instance of Command.") raise NotAFalyxError("command must be an instance of Command.")
if runner_hooks and not isinstance(runner_hooks, HookManager): if runner_hooks and not isinstance(runner_hooks, HookManager):
raise InvalidHookError("runner_hooks must be an instance of HookManager.") raise InvalidHookError("runner_hooks must be an instance of HookManager.")
return cls( bound_command = command.clone_with_overrides(
command=command, options_manager=options_manager,
program=program, program=program,
options=options, )
return cls(
command=bound_command,
program=program,
options_manager=options_manager,
runner_hooks=runner_hooks, runner_hooks=runner_hooks,
console=console, console=console,
) )
@@ -357,7 +369,7 @@ class CommandRunner:
spinner_type: str = "dots", spinner_type: str = "dots",
spinner_style: str = OneColors.CYAN, spinner_style: str = OneColors.CYAN,
spinner_speed: float = 1.0, spinner_speed: float = 1.0,
options: OptionsManager | None = None, options_manager: OptionsManager | None = None,
command_hooks: HookManager | None = None, command_hooks: HookManager | None = None,
before_hooks: list[Callable] | None = None, before_hooks: list[Callable] | None = None,
success_hooks: list[Callable] | None = None, success_hooks: list[Callable] | None = None,
@@ -415,7 +427,7 @@ class CommandRunner:
spinner_type (str): Spinner animation type. spinner_type (str): Spinner animation type.
spinner_style (str): Spinner style. spinner_style (str): Spinner style.
spinner_speed (float): Spinner speed multiplier. 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. and runner.
command_hooks (HookManager | None): Optional hook manager for the built command_hooks (HookManager | None): Optional hook manager for the built
command itself. command itself.
@@ -473,7 +485,7 @@ class CommandRunner:
- Command construction is delegated to `Command.build()` so command - Command construction is delegated to `Command.build()` so command
configuration remains centralized. configuration remains centralized.
""" """
options = options or OptionsManager() options_manager = options_manager or OptionsManager()
command = Command.build( command = Command.build(
key=key, key=key,
description=description, description=description,
@@ -499,7 +511,7 @@ class CommandRunner:
retry=retry, retry=retry,
retry_all=retry_all, retry_all=retry_all,
retry_policy=retry_policy, retry_policy=retry_policy,
options_manager=options, options_manager=options_manager,
hooks=command_hooks, hooks=command_hooks,
before_hooks=before_hooks, before_hooks=before_hooks,
success_hooks=success_hooks, success_hooks=success_hooks,
@@ -525,7 +537,7 @@ class CommandRunner:
return cls( return cls(
command=command, command=command,
options=options, options_manager=options_manager,
runner_hooks=runner_hooks, runner_hooks=runner_hooks,
console=console, console=console,
) )

View File

@@ -83,7 +83,7 @@ class FalyxCompleter(Completer):
- Detects preview-mode input prefixed with `?`. - Detects preview-mode input prefixed with `?`.
- Separates committed tokens from the active stub under the cursor. - Separates committed tokens from the active stub under the cursor.
- Resolves the partial route through `Falyx.resolve_completion_route()`. - 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 - Delegates leaf-command completion to
`CommandArgumentParser.suggest_next()` once a command is resolved. `CommandArgumentParser.suggest_next()` once a command is resolved.
- Preserves shell-safe quoting for suggestions containing spaces. - Preserves shell-safe quoting for suggestions containing spaces.
@@ -137,15 +137,14 @@ class FalyxCompleter(Completer):
# Still selecting an entry in the current namespace # Still selecting an entry in the current namespace
if route.expecting_entry: 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) 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: if route.is_preview:
suggestions = [f"?{s}" for s in suggestions] suggestions = [f"?{s}" for s in suggestions]
current_stub = f"?{route.stub}" if route.stub else "?" current_stub = f"?{route.stub}" if route.stub else "?"
@@ -171,7 +170,7 @@ class FalyxCompleter(Completer):
except Exception: except Exception:
return 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]: def _suggest_namespace_entries(self, namespace: Falyx, prefix: str) -> list[str]:
"""Return matching visible entry names for a namespace prefix. """Return matching visible entry names for a namespace prefix.
@@ -190,6 +189,7 @@ class FalyxCompleter(Completer):
""" """
results: list[str] = [] results: list[str] = []
for name in namespace.completion_names: for name in namespace.completion_names:
# results.append(name)
if name.upper().startswith(prefix.upper()): if name.upper().startswith(prefix.upper()):
results.append(name.lower() if prefix.islower() else name) results.append(name.lower() if prefix.islower() else name)
return results return results
@@ -207,7 +207,31 @@ class FalyxCompleter(Completer):
return f'"{text}"' return f'"{text}"'
return 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. """Yield completions for the current stub using longest-common-prefix logic.
Behavior: Behavior:

View File

@@ -59,6 +59,9 @@ class CompletionRoute:
leaf_argv (list[str]): Remaining command-local argv tokens that belong to leaf_argv (list[str]): Remaining command-local argv tokens that belong to
the resolved leaf command. These are typically passed to the the resolved leaf command. These are typically passed to the
command's argument parser for completion. 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 stub (str): The current token fragment under the cursor. This is the
partial text that completion candidates should replace or extend. partial text that completion candidates should replace or extend.
cursor_at_end_of_token (bool): Whether the cursor is positioned at the cursor_at_end_of_token (bool): Whether the cursor is positioned at the
@@ -81,6 +84,7 @@ class CompletionRoute:
context: InvocationContext context: InvocationContext
command: Command | None = None command: Command | None = None
leaf_argv: list[str] = field(default_factory=list) leaf_argv: list[str] = field(default_factory=list)
remaining_argv: list[str] = field(default_factory=list)
stub: str = "" stub: str = ""
cursor_at_end_of_token: bool = False cursor_at_end_of_token: bool = False
expecting_entry: bool = False expecting_entry: bool = False

View File

@@ -2,6 +2,7 @@
"""Global console instance for Falyx CLI applications.""" """Global console instance for Falyx CLI applications."""
from rich.console import Console from rich.console import Console
from falyx.exceptions import FalyxError
from falyx.themes import OneColors, get_nord_theme from falyx.themes import OneColors, get_nord_theme
console = Console(color_system="truecolor", theme=get_nord_theme()) console = Console(color_system="truecolor", theme=get_nord_theme())
@@ -13,6 +14,9 @@ def print_error(
*, *,
hint: str | None = None, hint: str | None = None,
) -> None: ) -> None:
if hint is None and isinstance(message, FalyxError):
hint = message.hint
error_console.print(f"[{OneColors.DARK_RED}]error:[/] {message}") error_console.print(f"[{OneColors.DARK_RED}]error:[/] {message}")
if hint: if hint:
error_console.print(f"[{OneColors.LIGHT_YELLOW}]hint:[/] {hint}") error_console.print(f"[{OneColors.LIGHT_YELLOW}]hint:[/] {hint}")

View File

@@ -209,12 +209,38 @@ class MissingValueError(ArgumentParsingError):
def __init__( def __init__(
self, self,
dest: str, dest: str,
expected_count: int | None = None, expected_count: int | str | None = None,
actual_count: int | 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.expected_count = expected_count
self.actual_count = actual_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): class TokenizationError(UsageError):

View File

@@ -256,7 +256,7 @@ class ExecutionRegistry:
if ctx.exception and status.lower() in ["all", "error"]: if ctx.exception and status.lower() in ["all", "error"]:
final_status = f"[{OneColors.DARK_RED}]❌ Error" final_status = f"[{OneColors.DARK_RED}]❌ Error"
final_result = repr(ctx.exception) 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_status = f"[{OneColors.GREEN}]✅ Success"
final_result = repr(ctx.result) final_result = repr(ctx.result)
if len(final_result) > 50: if len(final_result) > 50:

View File

@@ -64,11 +64,12 @@ import asyncio
import logging import logging
import shlex import shlex
import sys import sys
from collections.abc import Callable
from difflib import get_close_matches from difflib import get_close_matches
from functools import cached_property from functools import cached_property
from pathlib import Path from pathlib import Path
from random import choice from random import choice
from typing import Any, Callable from typing import Any
from prompt_toolkit import PromptSession from prompt_toolkit import PromptSession
from prompt_toolkit.application import get_app from prompt_toolkit.application import get_app
@@ -103,6 +104,7 @@ from falyx.exceptions import (
CommandArgumentError, CommandArgumentError,
EntryNotFoundError, EntryNotFoundError,
FalyxError, FalyxError,
FalyxOptionError,
InvalidActionError, InvalidActionError,
InvalidHookError, InvalidHookError,
NotAFalyxError, NotAFalyxError,
@@ -115,7 +117,8 @@ from falyx.logger import logger
from falyx.mode import FalyxMode from falyx.mode import FalyxMode
from falyx.namespace import FalyxNamespace from falyx.namespace import FalyxNamespace
from falyx.options_manager import OptionsManager 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.parser.parser_types import FalyxTLDRInput
from falyx.prompt_utils import rich_text_to_prompt_text from falyx.prompt_utils import rich_text_to_prompt_text
from falyx.protocols import ArgParserProtocol from falyx.protocols import ArgParserProtocol
@@ -128,7 +131,6 @@ from falyx.validators import CommandValidator
from falyx.version import __version__ from falyx.version import __version__
# TODO: better OptionsManager determination (assert same instance across a namespace)
class Falyx: class Falyx:
"""Primary controller for Falyx CLI applications. """Primary controller for Falyx CLI applications.
@@ -206,7 +208,7 @@ class Falyx:
builtins (dict[str, Command]): Registered built-in commands such as help, builtins (dict[str, Command]): Registered built-in commands such as help,
preview, and version. preview, and version.
namespaces (dict[str, FalyxNamespace]): Registered nested namespaces. 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. hooks (HookManager): Application-level hook manager.
console (Console): Rich console used for rendering output. console (Console): Rich console used for rendering output.
key_bindings (KeyBindings): Prompt Toolkit key bindings for menu mode. key_bindings (KeyBindings): Prompt Toolkit key bindings for menu mode.
@@ -254,7 +256,7 @@ class Falyx:
force_confirm: bool = False, force_confirm: bool = False,
verbose: bool = False, verbose: bool = False,
debug_hooks: bool = False, debug_hooks: bool = False,
options: OptionsManager | None = None, options_manager: OptionsManager | None = None,
render_menu: Callable[[Falyx], None] | None = None, render_menu: Callable[[Falyx], None] | None = None,
custom_table: Callable[[Falyx], Table] | Table | None = None, custom_table: Callable[[Falyx], Table] | Table | None = None,
hide_menu_table: bool = False, hide_menu_table: bool = False,
@@ -328,7 +330,7 @@ class Falyx:
runtime option. runtime option.
verbose (bool): Default session-level value for the `verbose` 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. 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. If omitted, a new `OptionsManager` instance is created.
render_menu (Callable[[Falyx], None] | None): Optional custom menu renderer render_menu (Callable[[Falyx], None] | None): Optional custom menu renderer
used instead of the default table-based menu output. used instead of the default table-based menu output.
@@ -355,7 +357,7 @@ class Falyx:
option from the root parser. option from the root parser.
Raises: 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. configuration is inconsistent.
Notes: Notes:
@@ -399,9 +401,9 @@ class Falyx:
self.custom_table: Callable[[Falyx], Table] | Table | None = custom_table self.custom_table: Callable[[Falyx], Table] | Table | None = custom_table
self._hide_menu_table: bool = hide_menu_table self._hide_menu_table: bool = hide_menu_table
self.show_placeholder_menu: bool = show_placeholder_menu 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._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.exit_command: Command = self._get_exit_command()
self.history_command: Command | None = ( self.history_command: Command | None = (
self._get_history_command() if include_history_command else 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.default_to_menu: bool = default_to_menu
self.simple_usage: bool = simple_usage self.simple_usage: bool = simple_usage
self._register_default_builtins() self._register_default_builtins()
self._register_options() self._register_runtime_options()
self._executor = CommandExecutor( self._executor = CommandExecutor(
options=self.options, options_manager=self.options_manager,
hooks=self.hooks, hooks=self.hooks,
) )
self.disable_verbose_option: bool = disable_verbose_option 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.disable_never_prompt_option: bool = disable_never_prompt_option
self.parser: FalyxParser = FalyxParser(self) 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( def add_tldr_example(
self, self,
*, *,
@@ -487,7 +521,7 @@ class Falyx:
program=self.program, program=self.program,
program_style=self.program_style, program_style=self.program_style,
typed_path=[], typed_path=[],
mode=self.options.get("mode"), mode=self.options_manager.get("mode"),
) )
@property @property
@@ -497,11 +531,11 @@ class Falyx:
Returns: Returns:
bool: `True` when the active mode is not `FalyxMode.MENU`. 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, self,
options: OptionsManager | None = None, options_manager: OptionsManager | None = None,
) -> None: ) -> None:
"""Validate and install the shared options manager. """Validate and install the shared options manager.
@@ -509,44 +543,47 @@ class Falyx:
stored on the instance. stored on the instance.
Args: Args:
options (OptionsManager | None): Optional options manager to reuse. options_manager (OptionsManager | None): Optional options manager to reuse.
Raises: 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() self.options_manager: OptionsManager = options_manager or OptionsManager()
if not isinstance(self.options, OptionsManager): if not isinstance(self.options_manager, OptionsManager):
raise NotAFalyxError("options must be an instance of 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. """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 menu visibility, and program display metadata exist in the shared options
manager. 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"): if not self.options_manager.has_option("never_prompt"):
self.options.set("never_prompt", self._never_prompt) self.options_manager.set("never_prompt", self._never_prompt, "root")
if not self.options.get("force_confirm"): if not self.options_manager.has_option("force_confirm"):
self.options.set("force_confirm", self._force_confirm) self.options_manager.set("force_confirm", self._force_confirm, "root")
if not self.options.get("verbose"): if not self.options_manager.has_option("verbose"):
self.options.set("verbose", self._verbose) self.options_manager.set("verbose", self._verbose, "root")
if not self.options.get("debug_hooks"): if not self.options_manager.has_option("debug_hooks"):
self.options.set("debug_hooks", self._debug_hooks) self.options_manager.set("debug_hooks", self._debug_hooks, "root")
if not self.options.get("hide_menu_table"): if not self.options_manager.has_option("hide_menu_table"):
self.options.set("hide_menu_table", self._hide_menu_table) self.options_manager.set("hide_menu_table", self._hide_menu_table)
if not self.options.get("program"): if not self.options_manager.has_option("program"):
self.options.set("program", self.program) self.options_manager.set("program", self.program)
if not self.options.get("program_style"): if not self.options_manager.has_option("program_style"):
self.options.set("program_style", self.program_style) self.options_manager.set("program_style", self.program_style)
@property @property
def completion_names(self) -> list[str]: def completion_names(self) -> list[str]:
@@ -670,7 +707,7 @@ class Falyx:
style=OneColors.DARK_RED, style=OneColors.DARK_RED,
simple_help_signature=True, simple_help_signature=True,
ignore_in_history=True, ignore_in_history=True,
options_manager=self.options, options_manager=self.options_manager,
program=self.program, program=self.program,
help_text="Exit the program.", help_text="Exit the program.",
) )
@@ -744,7 +781,7 @@ class Falyx:
argument_config=add_history_arguments, argument_config=add_history_arguments,
help_text="View the execution history of commands.", help_text="View the execution history of commands.",
ignore_in_history=True, ignore_in_history=True,
options_manager=self.options, options_manager=self.options_manager,
program=self.program, program=self.program,
) )
@@ -1313,7 +1350,7 @@ class Falyx:
style=OneColors.LIGHT_YELLOW, style=OneColors.LIGHT_YELLOW,
argument_config=add_help_arguments, argument_config=add_help_arguments,
ignore_in_history=True, ignore_in_history=True,
options_manager=self.options, options_manager=self.options_manager,
program=self.program, program=self.program,
) )
@@ -1364,7 +1401,7 @@ class Falyx:
aliases=["PREVIEW"], aliases=["PREVIEW"],
action=Action("Preview", self._preview), action=Action("Preview", self._preview),
style=OneColors.GREEN, style=OneColors.GREEN,
options_manager=self.options, options_manager=self.options_manager,
program=self.program, program=self.program,
help_text="Preview the execution of a command without running it.", help_text="Preview the execution of a command without running it.",
argument_config=add_preview_argument, argument_config=add_preview_argument,
@@ -1388,7 +1425,7 @@ class Falyx:
action=Action("Version", self._render_version), action=Action("Version", self._render_version),
style=self.version_style, style=self.version_style,
ignore_in_history=True, ignore_in_history=True,
options_manager=self.options, options_manager=self.options_manager,
program=self.program, program=self.program,
help_text=f"Show the {self.program} version.", help_text=f"Show the {self.program} version.",
) )
@@ -1664,7 +1701,7 @@ class Falyx:
confirm=confirm, confirm=confirm,
confirm_message=confirm_message, confirm_message=confirm_message,
ignore_in_history=True, ignore_in_history=True,
options_manager=self.options, options_manager=self.options_manager,
program=self.program, program=self.program,
help_text=help_text, help_text=help_text,
) )
@@ -1726,41 +1763,56 @@ class Falyx:
help_text="Go back to the previous menu.", 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. """Register multiple commands from instances or config dictionaries.
Args: Args:
commands (list[Command] | list[dict]): Sequence of `Command` objects or commands (list[Command] | list[dict]): Sequence of `Command` objects or
`add_command()` keyword dictionaries. `add_command()` keyword dictionaries.
Returns:
list[Command]: List of registered `Command` instances.
Raises: Raises:
FalyxError: If an element is neither a `Command` nor a configuration FalyxError: If an element is neither a `Command` nor a configuration
dictionary. dictionary.
""" """
added_commands = []
for command in commands: for command in commands:
if isinstance(command, dict): if isinstance(command, dict):
self.add_command(**command) added_commands.append(self.add_command(**command))
elif isinstance(command, Command): elif isinstance(command, Command):
self.add_command_from_command(command) added_commands.append(self.add_command_from_command(command))
else: else:
raise FalyxError( raise FalyxError(
"command must be a dictionary or an instance of Command." "command must be a dictionary or an instance of Command."
) )
return added_commands
def add_command_from_command(self, command: Command) -> None: def add_command_from_command(self, command: Command) -> Command:
"""Register an already-built `Command` object. """Registers a clone of the provided command and returns the bound clone
owned by this namespace.
Args: Args:
command (Command): Preconstructed command to add to this namespace. command (Command): Preconstructed command to add to this namespace.
Returns:
Command: The newly registered clone of the command instance bound to
this namespace.
Raises: Raises:
FalyxError: If `command` is not a `Command`. FalyxError: If `command` is not a `Command`.
""" """
if not isinstance(command, Command): if not isinstance(command, Command):
raise FalyxError("command must be an instance of Command.") raise FalyxError("command must be an instance of Command.")
self._validate_command_aliases(command.key, command.aliases) 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 _ = self._entry_map
return bound_command
def add_command( def add_command(
self, self,
@@ -1903,7 +1955,7 @@ class Falyx:
auto_args=auto_args, auto_args=auto_args,
arg_metadata=arg_metadata, arg_metadata=arg_metadata,
simple_help_signature=simple_help_signature, simple_help_signature=simple_help_signature,
options_manager=self.options, options_manager=self.options_manager,
ignore_in_history=ignore_in_history, ignore_in_history=ignore_in_history,
program=self.program, program=self.program,
) )
@@ -2146,7 +2198,7 @@ class Falyx:
program=self.program, program=self.program,
program_style=self.program_style, program_style=self.program_style,
typed_path=[], typed_path=[],
mode=mode or self.options.get("mode"), mode=mode or self.options_manager.get("mode"),
is_preview=is_preview, is_preview=is_preview,
) )
@@ -2253,6 +2305,7 @@ class Falyx:
EOFError: If execution receives an unexpected end of input and `wrap_errors` EOFError: If execution receives an unexpected end of input and `wrap_errors`
is `False`. is `False`.
""" """
route.namespace._apply_root_options()
if route.is_preview: if route.is_preview:
if route.kind is RouteKind.COMMAND and route.command: if route.kind is RouteKind.COMMAND and route.command:
logger.info("preview command '%s' selected.", route.command.key) logger.info("preview command '%s' selected.", route.command.key)
@@ -2286,6 +2339,10 @@ class Falyx:
if command is route.namespace.help_command: if command is route.namespace.help_command:
kwargs = kwargs or {} 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 kwargs["invocation_context"] = route.context
logger.debug( logger.debug(
@@ -2295,15 +2352,22 @@ class Falyx:
kwargs, kwargs,
execution_args, execution_args,
) )
return await self._executor.execute( route.namespace.options_manager.seed_missing(
command=route.command, route.namespace_defaults,
args=args,
kwargs=kwargs or {},
execution_args=execution_args or {},
raise_on_error=raise_on_error,
wrap_errors=wrap_errors,
summary_last_result=summary_last_result,
) )
with route.namespace.options_manager.override_namespace(
route.namespace_overrides,
"default",
):
return await self._executor.execute(
command=route.command,
args=args,
kwargs=kwargs or {},
execution_args=execution_args or {},
raise_on_error=raise_on_error,
wrap_errors=wrap_errors,
summary_last_result=summary_last_result,
)
async def execute_command( async def execute_command(
self, self,
@@ -2372,15 +2436,23 @@ class Falyx:
assert route is not None, "prepare_route should never return None." assert route is not None, "prepare_route should never return None."
return await self._dispatch_route( route.namespace.options_manager.seed_missing(
route=route, route.root_defaults,
args=args, namespace_name="root",
kwargs=kwargs,
execution_args=execution_args,
raise_on_error=raise_on_error,
wrap_errors=wrap_errors,
summary_last_result=summary_last_result,
) )
with route.namespace.options_manager.override_namespace(
route.root_overrides,
namespace_name="root",
):
return await self._dispatch_route(
route=route,
args=args,
kwargs=kwargs,
execution_args=execution_args,
raise_on_error=raise_on_error,
wrap_errors=wrap_errors,
summary_last_result=summary_last_result,
)
def resolve_completion_route( def resolve_completion_route(
self, self,
@@ -2409,21 +2481,57 @@ class Falyx:
""" """
namespace = self namespace = self
route_context = invocation_context route_context = invocation_context
remaining = list(committed_tokens) remaining_in_namespace = [stub]
remaining = list(committed_tokens)
while remaining: 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) head = remaining.pop(0)
entry, _ = namespace.resolve_entry(head) entry, _ = namespace.resolve_entry(head)
if entry is None: 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. # Still routing namespace entries; could not resolve this token.
# Let the completer suggest entries or namespace-level flags. # Let the completer suggest entries or namespace-level flags.
return CompletionRoute( return CompletionRoute(
namespace=namespace, namespace=namespace,
context=route_context, context=route_context,
command=None, command=None,
leaf_argv=[], remaining_argv=remaining_in_namespace,
stub=head if not remaining else stub, stub=head,
cursor_at_end_of_token=cursor_at_end_of_token, cursor_at_end_of_token=cursor_at_end_of_token,
expecting_entry=True, expecting_entry=True,
is_preview=is_preview, is_preview=is_preview,
@@ -2453,6 +2561,7 @@ class Falyx:
context=route_context, context=route_context,
command=None, command=None,
leaf_argv=[], leaf_argv=[],
remaining_argv=remaining_in_namespace,
stub=stub, stub=stub,
cursor_at_end_of_token=cursor_at_end_of_token, cursor_at_end_of_token=cursor_at_end_of_token,
expecting_entry=True, expecting_entry=True,
@@ -2465,6 +2574,8 @@ class Falyx:
*, *,
invocation_context: InvocationContext, invocation_context: InvocationContext,
is_preview: bool = False, is_preview: bool = False,
root_defaults: dict[str, Any] | None = None,
root_overrides: dict[str, Any] | None = None,
) -> RouteResult: ) -> RouteResult:
"""Resolve an invocation path across namespaces until a leaf boundary. """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 # 1. Namespace-level parsing for help/tldr flags and root/session options
parse_result = self.parser.parse_args(tokens) 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 tokens = parse_result.remaining_argv
# 2. Help or TLDR requested for this namespace # 2. Help or TLDR requested for this namespace
@@ -2507,12 +2624,22 @@ class Falyx:
) )
# 3. No more tokens -> this namespace itself was targeted # 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( return RouteResult(
kind=RouteKind.NAMESPACE_MENU, kind=RouteKind.NAMESPACE_MENU,
namespace=self, namespace=self,
context=invocation_context, context=invocation_context,
is_preview=is_preview, is_preview=is_preview,
root_defaults=parse_result.root_defaults,
root_overrides=parse_result.root_options,
) )
head, *tail = tokens head, *tail = tokens
@@ -2534,7 +2661,11 @@ class Falyx:
# 5. Namespace entry -> recurse with remaining tokens # 5. Namespace entry -> recurse with remaining tokens
if isinstance(entry, FalyxNamespace): if isinstance(entry, FalyxNamespace):
return await entry.namespace.resolve_route( 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 # 6. Leaf command -> stop routing; leave tail untouched for leaf parser
@@ -2546,6 +2677,10 @@ class Falyx:
leaf_argv=tail, leaf_argv=tail,
current_head=head, current_head=head,
is_preview=is_preview, 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: async def _process_command(self) -> None:
@@ -2577,12 +2712,12 @@ class Falyx:
welcome and exit messages. welcome and exit messages.
""" """
logger.info("Starting menu: %s", self.title) logger.info("Starting menu: %s", self.title)
self.options.set("mode", FalyxMode.MENU) self.options_manager.set("mode", FalyxMode.MENU)
if self.welcome_message: if self.welcome_message:
self.console.print(self.welcome_message) self.console.print(self.welcome_message)
try: try:
while True: 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): if callable(self.render_menu):
self.render_menu(self) self.render_menu(self)
else: else:
@@ -2608,32 +2743,20 @@ class Falyx:
if self.exit_message: if self.exit_message:
self.console.print(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. """Apply parsed root/session options to runtime state.
This updates the active mode, logging verbosity, debug-hook registration, This updates logging verbosity and debug-hook registration.
and prompt behavior based on the root parse result.
Args:
result (ParseResult): Parsed root CLI result to apply.
""" """
self.options.set("mode", result.mode) falyx_logger = logging.getLogger("falyx")
if self.options_manager.get("verbose", False, "root"):
if result.verbose: falyx_logger.setLevel(logging.DEBUG)
logging.getLogger("falyx").setLevel(logging.DEBUG)
self.options.set("verbose", True)
else: else:
self.options.set("verbose", False) falyx_logger.setLevel(logging.WARNING)
if result.debug_hooks: if self.options_manager.get("debug_hooks", False, "root"):
self.options.set("debug_hooks", True)
self.register_all_with_debug_hooks() self.register_all_with_debug_hooks()
logger.debug("Enabling global debug hooks for all commands") 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: async def run(self, always_start_menu: bool = False) -> None:
"""Execute the Falyx application using CLI-driven dispatch. """Execute the Falyx application using CLI-driven dispatch.
@@ -2690,21 +2813,29 @@ class Falyx:
assert route is not None, "prepare_route should never return None." assert route is not None, "prepare_route should never return None."
try: try:
await self._dispatch_route( route.namespace.options_manager.seed_missing(
route=route, route.root_defaults,
args=args, namespace_name="root",
kwargs=kwargs,
execution_args=execution_args,
raise_on_error=False,
wrap_errors=True,
) )
with route.namespace.options_manager.override_namespace(
route.root_overrides,
namespace_name="root",
):
await self._dispatch_route(
route=route,
args=args,
kwargs=kwargs,
execution_args=execution_args,
raise_on_error=False,
wrap_errors=True,
)
except EntryNotFoundError as error: except EntryNotFoundError as error:
await self.render_help() await self.render_help()
print_error(message=error) print_error(message=error)
sys.exit(2) sys.exit(2)
except (FalyxError, Exception) as error: except (FalyxError, Exception) as error:
print_error(message=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) logger.error("Error: %s", error, exc_info=True)
sys.exit(1) sys.exit(1)
except QuitSignal: except QuitSignal:

View File

@@ -179,3 +179,10 @@ class HookManager:
hook_list = self._hooks.get(hook_type, []) hook_list = self._hooks.get(hook_type, [])
lines.append(f" {hook_type.value}: {format_hook_list(hook_list)}") lines.append(f" {hook_type.value}: {format_hook_list(hook_list)}")
return "\n".join(lines) return "\n".join(lines)
def copy(self) -> HookManager:
"""Create a deep copy of this HookManager, including all registered hooks."""
new_manager = HookManager()
for hook_type, hooks in self._hooks.items():
new_manager._hooks[hook_type] = list(hooks)
return new_manager

View File

@@ -68,6 +68,14 @@ class MenuOption:
[(OneColors.WHITE, f"[{key}] "), (self.style, self.description)] [(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): class MenuOptionMap(CaseInsensitiveDict):
""" """
@@ -160,3 +168,13 @@ class MenuOptionMap(CaseInsensitiveDict):
if not include_reserved and key in self.RESERVED_KEYS: if not include_reserved and key in self.RESERVED_KEYS:
continue continue
yield key, option yield key, option
def copy(self) -> MenuOptionMap:
"""Create a copy of this MenuOptionMap."""
items = {}
for key, option in self.items():
if key in self.RESERVED_KEYS and not self.allow_reserved:
continue
items[key] = option.copy()
new_map = MenuOptionMap(items, allow_reserved=self.allow_reserved)
return new_map

View File

@@ -37,6 +37,7 @@ Attributes:
""" """
from collections import defaultdict from collections import defaultdict
from contextlib import contextmanager from contextlib import contextmanager
from copy import deepcopy
from typing import Any, Callable, Iterator, Mapping from typing import Any, Callable, Iterator, Mapping
from falyx.logger import logger from falyx.logger import logger
@@ -227,21 +228,50 @@ class OptionsManager:
return _toggle return _toggle
def get_namespace_dict(self, namespace_name: str) -> dict[str, Any]: def get_namespace(self, namespace_name: str) -> dict[str, Any] | None:
"""Return a shallow copy of one namespace's option dictionary. """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: Args:
namespace_name (str): Namespace to snapshot. namespace_name (str): Namespace to snapshot.
Returns: Returns:
dict[str, Any]: Copy of the namespace's stored options. 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: if namespace_name not in self.options:
raise ValueError(f"Namespace '{namespace_name}' not found.") return None
return dict(self.options[namespace_name]) 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 @contextmanager
def override_namespace( def override_namespace(
@@ -267,9 +297,16 @@ class OptionsManager:
Raises: Raises:
ValueError: If the namespace does not already exist. 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: try:
self.from_mapping(values=overrides, namespace_name=namespace_name) self.from_mapping(values=overrides, namespace_name=namespace_name)
yield yield
finally: finally:
self.options[namespace_name] = original self.options[namespace_name] = original
def __str__(self) -> str:
return f"OptionsManager(namespaces={list(self.options.keys())})"

View File

@@ -7,7 +7,7 @@ Licensed under the MIT License. See LICENSE file for details.
from .argument import Argument from .argument import Argument
from .argument_action import ArgumentAction from .argument_action import ArgumentAction
from .command_argument_parser import CommandArgumentParser from .command_argument_parser import CommandArgumentParser
from .falyx_parser import FalyxParser from .falyx_parser import FalyxParser, OptionAction
from .parse_result import ParseResult from .parse_result import ParseResult
__all__ = [ __all__ = [
@@ -15,5 +15,6 @@ __all__ = [
"ArgumentAction", "ArgumentAction",
"CommandArgumentParser", "CommandArgumentParser",
"FalyxParser", "FalyxParser",
"OptionAction",
"ParseResult", "ParseResult",
] ]

View File

@@ -32,6 +32,8 @@ Used By:
- Rich-based CLI help generation - Rich-based CLI help generation
- Completion and preview suggestions - Completion and preview suggestions
""" """
from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
@@ -155,3 +157,23 @@ class Argument:
self.mutex_group, self.mutex_group,
) )
) )
def copy(self) -> Argument:
"""Create a copy of this Argument."""
return Argument(
flags=self.flags,
dest=self.dest,
action=self.action,
type=self.type,
default=self.default,
choices=list(self.choices) if self.choices else [],
required=self.required,
help=self.help,
nargs=self.nargs,
positional=self.positional,
resolver=self.resolver,
lazy_resolver=self.lazy_resolver,
suggestions=list(self.suggestions) if self.suggestions else None,
group=self.group,
mutex_group=self.mutex_group,
)

View File

@@ -46,7 +46,9 @@ general-purpose argparse replacement.
""" """
from __future__ import annotations from __future__ import annotations
import re
from collections import Counter, defaultdict from collections import Counter, defaultdict
from collections.abc import Callable
from copy import deepcopy from copy import deepcopy
from pathlib import Path from pathlib import Path
from typing import Any, Generator, Iterable from typing import Any, Generator, Iterable
@@ -121,7 +123,7 @@ class _GroupBuilder:
action: str | ArgumentAction = "store", action: str | ArgumentAction = "store",
nargs: int | str | None = None, nargs: int | str | None = None,
default: Any = None, default: Any = None,
type: Any = str, type: Callable[[Any], Any] = str,
choices: Iterable | None = None, choices: Iterable | None = None,
required: bool = False, required: bool = False,
help: str = "", help: str = "",
@@ -129,8 +131,8 @@ class _GroupBuilder:
resolver: BaseAction | None = None, resolver: BaseAction | None = None,
lazy_resolver: bool = True, lazy_resolver: bool = True,
suggestions: list[str] | None = None, suggestions: list[str] | None = None,
) -> None: ) -> Argument:
self.parser.add_argument( return self.parser.add_argument(
*flags, *flags,
action=action, action=action,
nargs=nargs, nargs=nargs,
@@ -201,6 +203,7 @@ class CommandArgumentParser:
self.help_epilog: str = help_epilog self.help_epilog: str = help_epilog
self.aliases: list[str] = aliases or [] self.aliases: list[str] = aliases or []
self.program: str | None = program self.program: str | None = program
self._arguments: list[Argument] = [] self._arguments: list[Argument] = []
self._positional: dict[str, Argument] = {} self._positional: dict[str, Argument] = {}
self._keyword: dict[str, Argument] = {} self._keyword: dict[str, Argument] = {}
@@ -208,19 +211,26 @@ class CommandArgumentParser:
self._flag_map: dict[str, Argument] = {} self._flag_map: dict[str, Argument] = {}
self._dest_set: set[str] = set() self._dest_set: set[str] = set()
self._execution_dests: set[str] = set() self._execution_dests: set[str] = set()
self._add_help() self._add_help()
self._last_positional_states: dict[str, ArgumentState] = {} self._last_positional_states: dict[str, ArgumentState] = {}
self._last_keyword_states: dict[str, ArgumentState] = {} self._last_keyword_states: dict[str, ArgumentState] = {}
self._argument_groups: dict[str, ArgumentGroup] = {} self._argument_groups: dict[str, ArgumentGroup] = {}
self._mutex_groups: dict[str, MutuallyExclusiveGroup] = {} self._mutex_groups: dict[str, MutuallyExclusiveGroup] = {}
self._arg_group_by_dest: dict[str, str] = {} self._arg_group_by_dest: dict[str, str] = {}
self._mutex_group_by_dest: dict[str, str] = {} self._mutex_group_by_dest: dict[str, str] = {}
self._tldr_examples: list[TLDRExample] = [] self._tldr_examples: list[TLDRExample] = []
self._is_help_command: bool = False self._is_help_command: bool = False
if tldr_examples: if tldr_examples:
self.add_tldr_examples(tldr_examples) self.add_tldr_examples(tldr_examples)
self.options_manager: OptionsManager = options_manager or OptionsManager() self.options_manager: OptionsManager = options_manager or OptionsManager()
self._is_runner_mode: bool = False 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: def mark_as_help_command(self) -> None:
"""Mark this parser as the help command parser.""" """Mark this parser as the help command parser."""
@@ -247,15 +257,16 @@ class CommandArgumentParser:
execution_options: frozenset[ExecutionOption], execution_options: frozenset[ExecutionOption],
) -> None: ) -> None:
"""Enable support for execution options like retries, summary, etc.""" """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( self.add_argument(
"--summary", "--summary",
action=ArgumentAction.STORE_TRUE, action=ArgumentAction.STORE_TRUE,
help="Print an execution summary after command completes", help="Print an execution summary after command completes",
) )
self._register_execution_dest("summary") 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( self.add_argument(
"--retries", "--retries",
type=int, type=int,
@@ -277,8 +288,9 @@ class CommandArgumentParser:
help="Backoff multiplier for retries (e.g. 2.0 doubles the delay each retry)", help="Backoff multiplier for retries (e.g. 2.0 doubles the delay each retry)",
) )
self._register_execution_dest("retry_backoff") 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( self.add_argument(
"--confirm", "--confirm",
dest="force_confirm", dest="force_confirm",
@@ -292,6 +304,7 @@ class CommandArgumentParser:
help="Skip confirmation prompts", help="Skip confirmation prompts",
) )
self._register_execution_dest("skip_confirm") self._register_execution_dest("skip_confirm")
self._confirm_enabled = True
def _register_execution_dest(self, dest: str) -> None: def _register_execution_dest(self, dest: str) -> None:
"""Register a destination as an execution argument.""" """Register a destination as an execution argument."""
@@ -312,6 +325,7 @@ class CommandArgumentParser:
action=ArgumentAction.HELP, action=ArgumentAction.HELP,
help="Show this help message.", help="Show this help message.",
dest="help", dest="help",
choices=[],
) )
self._register_argument(help) self._register_argument(help)
@@ -322,6 +336,7 @@ class CommandArgumentParser:
action=ArgumentAction.TLDR, action=ArgumentAction.TLDR,
help="Show quick usage examples.", help="Show quick usage examples.",
dest="tldr", dest="tldr",
choices=[],
) )
self._register_argument(tldr) self._register_argument(tldr)
@@ -544,44 +559,47 @@ class CommandArgumentParser:
raise CommandArgumentError( raise CommandArgumentError(
"choices must be iterable (like list, tuple, or set)" "choices must be iterable (like list, tuple, or set)"
) from error ) from error
normalized: list[Any] = []
for choice in choices: for choice in choices:
try: try:
coerce_value(choice, expected_type) normalized.append(coerce_value(choice, expected_type))
except Exception as error: except Exception as error:
type_name = get_type_name(expected_type) type_name = get_type_name(expected_type)
raise CommandArgumentError( raise CommandArgumentError(
f"invalid choice {choice!r}: cannot be coerced to {type_name} error: {error}" f"invalid choice {choice!r}: cannot be coerced to {type_name} error: {error}"
) from error ) from error
return choices return normalized
def _validate_default_type( def _normalize_default_type(
self, default: Any, expected_type: type, dest: str self, default: Any, expected_type: Callable[[Any], Any], dest: str
) -> None: ) -> Any:
"""Validate the default value type.""" """Normalize the default value type."""
if default is None: if default is None:
return None return None
try: try:
coerce_value(default, expected_type) return coerce_value(default, expected_type)
except Exception as error: except Exception as error:
type_name = get_type_name(expected_type) type_name = get_type_name(expected_type)
raise CommandArgumentError( raise CommandArgumentError(
f"invalid default value {default!r} for '{dest}' cannot be coerced to {type_name} error: {error}" f"invalid default value {default!r} for '{dest}' cannot be coerced to {type_name} error: {error}"
) from error ) from error
def _validate_default_list_type( def _normalize_default_list_type(
self, default: list[Any], expected_type: type, dest: str self, default: list[Any], expected_type: Callable[[Any], Any], dest: str
) -> None: ) -> list[Any] | None:
"""Validate the default value type for a list.""" """Normalize the default value type for a list."""
if not isinstance(default, list): if not isinstance(default, list):
return None return None
normalized: list[Any] = []
for item in default: for item in default:
try: try:
coerce_value(item, expected_type) normalized.append(coerce_value(item, expected_type))
except Exception as error: except Exception as error:
type_name = get_type_name(expected_type) type_name = get_type_name(expected_type)
raise CommandArgumentError( raise CommandArgumentError(
f"invalid default list value {default!r} for '{dest}' cannot be coerced to {type_name} error: {error}" f"invalid default list value {default!r} for '{dest}' cannot be coerced to {type_name} error: {error}"
) from error ) from error
return normalized
def _validate_resolver( def _validate_resolver(
self, action: ArgumentAction, resolver: BaseAction | None self, action: ArgumentAction, resolver: BaseAction | None
@@ -699,6 +717,10 @@ class CommandArgumentParser:
raise CommandArgumentError( raise CommandArgumentError(
f"invalid flag '{flag}': short flags must be a single character" 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( def _register_store_bool_optional(
self, self,
@@ -707,7 +729,7 @@ class CommandArgumentParser:
help: str, help: str,
group: str | None, group: str | None,
mutex_group: str | None, mutex_group: str | None,
) -> None: ) -> Argument:
"""Register a store_bool_optional action with the parser.""" """Register a store_bool_optional action with the parser."""
if len(flags) != 1: if len(flags) != 1:
raise CommandArgumentError( raise CommandArgumentError(
@@ -743,6 +765,7 @@ class CommandArgumentParser:
self._register_argument(argument) self._register_argument(argument)
self._register_argument(negated_argument, bypass_validation=True) self._register_argument(negated_argument, bypass_validation=True)
return argument
def _register_argument( def _register_argument(
self, argument: Argument, bypass_validation: bool = False self, argument: Argument, bypass_validation: bool = False
@@ -771,19 +794,19 @@ class CommandArgumentParser:
if argument.group: if argument.group:
self._arg_group_by_dest[argument.dest] = 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: if argument.mutex_group:
self._mutex_group_by_dest[argument.dest] = 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( def add_argument(
self, self,
*flags, *flags: str,
action: str | ArgumentAction = "store", action: str | ArgumentAction = "store",
nargs: int | str | None = None, nargs: int | str | None = None,
default: Any = None, default: Any = None,
type: Any = str, type: Callable[[Any], Any] = str,
choices: Iterable | None = None, choices: Iterable | None = None,
required: bool = False, required: bool = False,
help: str = "", help: str = "",
@@ -793,7 +816,7 @@ class CommandArgumentParser:
suggestions: list[str] | None = None, suggestions: list[str] | None = None,
group: str | None = None, group: str | None = None,
mutex_group: str | None = None, mutex_group: str | None = None,
) -> None: ) -> Argument:
"""Define a new argument for the parser. """Define a new argument for the parser.
Supports positional and flagged arguments, type coercion, default values, Supports positional and flagged arguments, type coercion, default values,
@@ -841,9 +864,9 @@ class CommandArgumentParser:
and default is not None and default is not None
): ):
if isinstance(default, list): if isinstance(default, list):
self._validate_default_list_type(default, type, dest) default = self._normalize_default_list_type(default, type, dest)
else: else:
self._validate_default_type(default, type, dest) default = self._normalize_default_type(default, type, dest)
choices = self._normalize_choices(choices, type, action) choices = self._normalize_choices(choices, type, action)
if default is not None and choices: if default is not None and choices:
choices_str = ", ".join((str(choice) for choice in 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}" f"lazy_resolver must be a boolean, got {type_name}"
) )
if action == ArgumentAction.STORE_BOOL_OPTIONAL: if action == ArgumentAction.STORE_BOOL_OPTIONAL:
self._register_store_bool_optional(flags, dest, help, group, mutex_group) return self._register_store_bool_optional(
return None flags, dest, help, group, mutex_group
)
argument = Argument( argument = Argument(
flags=flags, flags=flags,
dest=dest, dest=dest,
@@ -893,6 +917,7 @@ class CommandArgumentParser:
mutex_group=mutex_group, mutex_group=mutex_group,
) )
self._register_argument(argument) self._register_argument(argument)
return argument
def get_argument(self, dest: str) -> Argument | None: def get_argument(self, dest: str) -> Argument | None:
"""Return the Argument object for a given destination name. """Return the Argument object for a given destination name.
@@ -944,6 +969,8 @@ class CommandArgumentParser:
if not spec.choices: if not spec.choices:
return None return None
value_check = result.get(spec.dest) value_check = result.get(spec.dest)
if not self._is_present(spec, value_check):
return None
if isinstance(value_check, list): if isinstance(value_check, list):
if all(value in spec.choices for value in value_check): if all(value in spec.choices for value in value_check):
return None return None
@@ -982,23 +1009,23 @@ class CommandArgumentParser:
and spec.nargs in ("+", "*", "?") and spec.nargs in ("+", "*", "?")
), f"Invalid nargs value: {spec.nargs}" ), f"Invalid nargs value: {spec.nargs}"
values = [] values = []
display_name = spec.flags[0] if spec.flags else spec.dest
if isinstance(spec.nargs, int): if isinstance(spec.nargs, int):
if index + spec.nargs > len(args): if index + spec.nargs > len(args):
raise MissingValueError( raise MissingValueError(
spec.dest, dest=spec.dest,
expected_count=spec.nargs, expected_count=spec.nargs,
actual_count=len(args) - index, 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] values = args[index : index + spec.nargs]
return values, index + spec.nargs return values, index + spec.nargs
elif spec.nargs == "+": elif spec.nargs == "+":
if index >= len(args): if index >= len(args):
raise MissingValueError(spec.dest, expected_count=1) raise MissingValueError(
raise CommandArgumentError( dest=spec.dest,
f"Expected at least one value for '{spec.dest}'" expected_count="+",
display_name=display_name,
) )
while index < len(args) and args[index] not in self._keyword: while index < len(args) and args[index] not in self._keyword:
values.append(args[index]) values.append(args[index])
@@ -1090,26 +1117,20 @@ class CommandArgumentParser:
if spec.nargs == "+" and len(typed) == 0: if spec.nargs == "+" and len(typed) == 0:
raise MissingValueError( raise MissingValueError(
dest=spec.dest, 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: if isinstance(spec.nargs, int) and len(typed) != spec.nargs:
raise MissingValueError( raise MissingValueError(
spec.dest, dest=spec.dest,
expected_count=spec.nargs, expected_count=spec.nargs,
actual_count=len(typed), 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: if not spec.lazy_resolver or not from_validate:
try: try:
result[spec.dest] = await spec.resolver(*typed) result[spec.dest] = await spec.resolver(*typed)
except Exception as error: except Exception as error:
raise CommandArgumentError( raise ArgumentParsingError(
f"[{spec.dest}] Action failed: {error}" f"[{spec.dest}] action failed: {error}"
) from error ) from error
self._check_if_in_choices(spec, result, arg_states) self._check_if_in_choices(spec, result, arg_states)
arg_states[spec.dest].set_consumed(base_index + index) arg_states[spec.dest].set_consumed(base_index + index)
@@ -1143,8 +1164,8 @@ class CommandArgumentParser:
self._raise_remaining_args_error(token, arg_states) self._raise_remaining_args_error(token, arg_states)
else: else:
plural = "s" if len(args[index:]) > 1 else "" plural = "s" if len(args[index:]) > 1 else ""
raise CommandArgumentError( raise ArgumentParsingError(
f"Unexpected positional argument{plural}: {', '.join(args[index:])}" f"unexpected positional argument{plural}: {', '.join(args[index:])}"
) )
return index return index
@@ -1211,22 +1232,22 @@ class CommandArgumentParser:
if choices: if choices:
choices.append(help_text) choices.append(help_text)
choices_text = ", ".join(choices) choices_text = ", ".join(choices)
raise CommandArgumentError( raise ArgumentParsingError(
f"Argument '{spec.dest}' requires a value. {choices_text}" f"argument '{spec.dest}' requires a value. {choices_text}"
) )
elif spec.nargs is None: elif spec.nargs is None:
try: try:
type_name = get_type_name(spec.type) type_name = get_type_name(spec.type)
raise CommandArgumentError( raise ArgumentParsingError(
f"Enter a {type_name} value for '{spec.dest}'. {help_text}" f"enter a {type_name} value for '{spec.dest}'. {help_text}"
) )
except AttributeError as error: except AttributeError as error:
raise CommandArgumentError( raise ArgumentParsingError(
f"Enter a value for '{spec.dest}'. {help_text}" f"enter a value for '{spec.dest}'. {help_text}"
) from error ) from error
else: else:
raise CommandArgumentError( raise ArgumentParsingError(
f"Argument '{spec.dest}' requires a value. Expected {spec.nargs} values. {help_text}" f"argument '{spec.dest}' requires a value. Expected {spec.nargs} values. {help_text}"
) )
async def _handle_token( async def _handle_token(
@@ -1280,8 +1301,8 @@ class CommandArgumentParser:
try: try:
result[spec.dest] = await spec.resolver(*typed_values) result[spec.dest] = await spec.resolver(*typed_values)
except Exception as error: except Exception as error:
raise CommandArgumentError( raise ArgumentParsingError(
f"[{spec.dest}] Action failed: {error}" f"[{spec.dest}] action failed: {error}"
) from error ) from error
self._check_if_in_choices(spec, result, arg_states) self._check_if_in_choices(spec, result, arg_states)
arg_states[spec.dest].set_consumed(new_index) arg_states[spec.dest].set_consumed(new_index)
@@ -1431,8 +1452,8 @@ class CommandArgumentParser:
present.append(dest) present.append(dest)
if len(present) > 1: if len(present) > 1:
raise CommandArgumentError( raise ArgumentParsingError(
f"Arguments in mutually exclusive group '{group.name}' " f"arguments in mutually exclusive group '{group.name}' "
f"cannot be used together: {', '.join(present)}" f"cannot be used together: {', '.join(present)}"
) )
@@ -1442,8 +1463,8 @@ class CommandArgumentParser:
spec = self.get_argument(dest) spec = self.get_argument(dest)
if spec: if spec:
members.append(spec.flags[0] if spec.flags else dest) members.append(spec.flags[0] if spec.flags else dest)
raise CommandArgumentError( raise ArgumentParsingError(
f"One of the following is required for group '{group.name}': " f"one of the following is required for group '{group.name}': "
f"{', '.join(members)}" f"{', '.join(members)}"
) )
@@ -1543,8 +1564,8 @@ class CommandArgumentParser:
and not arg.default and not arg.default
] ]
if missing_positionals: if missing_positionals:
raise CommandArgumentError( raise ArgumentParsingError(
f"Missing positional argument(s): {', '.join(missing_positionals)}" f"missing positional argument(s): {', '.join(missing_positionals)}"
) )
# Required validation # Required validation
@@ -1560,13 +1581,13 @@ class CommandArgumentParser:
): ):
if not args: if not args:
arg_states[spec.dest].reset() arg_states[spec.dest].reset()
raise CommandArgumentError( raise ArgumentParsingError(
f"Missing required argument '{spec.dest}': {spec.get_choice_text()}{help_text}" f"missing required argument '{spec.dest}': {spec.get_choice_text()}{help_text}"
) )
continue # Lazy resolvers are not validated here continue # Lazy resolvers are not validated here
arg_states[spec.dest].reset() arg_states[spec.dest].reset()
raise CommandArgumentError( raise ArgumentParsingError(
f"Missing required argument '{spec.dest}': {spec.get_choice_text()}{help_text}" f"missing required argument '{spec.dest}': {spec.get_choice_text()}{help_text}"
) )
self._check_if_in_choices(spec, result, arg_states) 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 "" help_text = f" help: {spec.help}" if spec.help else ""
if not result[spec.dest]: if not result[spec.dest]:
arg_states[spec.dest].reset() arg_states[spec.dest].reset()
raise CommandArgumentError( raise ArgumentParsingError(
f"Argument '{spec.dest}' requires at least one value{help_text}" f"argument '{spec.dest}' requires at least one value{help_text}"
) )
self._validate_mutex_groups(result) self._validate_mutex_groups(result)
@@ -2322,3 +2343,63 @@ class CommandArgumentParser:
def __repr__(self) -> str: def __repr__(self) -> str:
return str(self) return str(self)
def clone_with_overrides(
self,
*,
command_key: str | None = None,
command_description: str | None = None,
command_style: StyleType | None = None,
help_text: str | None = None,
help_epilog: str | None = None,
aliases: list[str] | None = None,
tldr_examples: list[TLDRInput] | None = None,
program: str | None = None,
options_manager: OptionsManager | None = None,
) -> CommandArgumentParser:
"""Create a copy of this parser with optional overrides for core properties."""
if tldr_examples is not None:
tldr_examples_copied = tldr_examples
elif self._tldr_examples is not None:
tldr_examples_copied = [example.copy() for example in self._tldr_examples]
else:
tldr_examples_copied = None
parser = CommandArgumentParser(
command_key=command_key if command_key is not None else self.command_key,
command_description=(
command_description
if command_description is not None
else self.command_description
),
command_style=(
command_style if command_style is not None else self.command_style
),
help_text=help_text if help_text is not None else self.help_text,
help_epilog=help_epilog if help_epilog is not None else self.help_epilog,
aliases=aliases if aliases is not None else self.aliases.copy(),
program=program if program is not None else self.program,
tldr_examples=tldr_examples_copied,
options_manager=(
options_manager if options_manager is not None else self.options_manager
),
)
parser._argument_groups = {
name: group.copy() for name, group in self._argument_groups.items()
}
parser._mutex_groups = {
name: group.copy() for name, group in self._mutex_groups.items()
}
for argument in self._arguments:
if argument.dest in {"help", "tldr"}:
continue
parser._register_argument(argument.copy())
parser._execution_dests = set(self._execution_dests)
parser._summary_enabled = self._summary_enabled
parser._retries_enabled = self._retries_enabled
parser._confirm_enabled = self._confirm_enabled
parser._is_runner_mode = self._is_runner_mode
parser._is_help_command = self._is_help_command
return parser

View File

@@ -1,18 +1,19 @@
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass import re
from enum import Enum from collections.abc import Callable
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from falyx.console import console
from falyx.exceptions import EntryNotFoundError, FalyxOptionError from falyx.exceptions import EntryNotFoundError, FalyxOptionError
from falyx.mode import FalyxMode 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.parse_result import ParseResult
from falyx.parser.parser_types import ( from falyx.parser.parser_types import (
FalyxTLDRExample, FalyxTLDRExample,
FalyxTLDRInput, FalyxTLDRInput,
OptionState,
false_none, false_none,
true_none, true_none,
) )
@@ -24,79 +25,6 @@ if TYPE_CHECKING:
builtin_type = type 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: class FalyxParser:
RESERVED_DESTS: set[str] = {"help", "tldr"} RESERVED_DESTS: set[str] = {"help", "tldr"}
@@ -106,9 +34,10 @@ class FalyxParser:
self._options: list[Option] = [] self._options: list[Option] = []
self._dest_set: set[str] = set() self._dest_set: set[str] = set()
self._tldr_examples: list[FalyxTLDRExample] = [] self._tldr_examples: list[FalyxTLDRExample] = []
self._add_reserved_options()
self.help_option: Option | None = None self.help_option: Option | None = None
self.tldr_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]: def get_flags(self) -> list[str]:
"""Return a list of the first flag for the registered options.""" """Return a list of the first flag for the registered options."""
@@ -119,7 +48,7 @@ class FalyxParser:
return self._options return self._options
def _add_tldr(self): def _add_tldr(self):
"""Add TLDR argument to the parser.""" """Add TLDR option to the parser."""
if "tldr" in self._dest_set: if "tldr" in self._dest_set:
return None return None
tldr = Option( tldr = Option(
@@ -208,7 +137,7 @@ class FalyxParser:
def _add_reserved_options(self) -> None: def _add_reserved_options(self) -> None:
help = Option( help = Option(
flags=("-h", "--help", "?"), flags=("-h", "--help"),
dest="help", dest="help",
action=OptionAction.HELP, action=OptionAction.HELP,
help="Show root-level help output and exit.", help="Show root-level help output and exit.",
@@ -255,7 +184,7 @@ class FalyxParser:
flags: tuple[str, ...], flags: tuple[str, ...],
dest: str, dest: str,
help: str, help: str,
) -> None: ) -> Option:
"""Register a store_bool_optional action with the parser.""" """Register a store_bool_optional action with the parser."""
if len(flags) != 1: if len(flags) != 1:
raise FalyxOptionError( raise FalyxOptionError(
@@ -268,7 +197,7 @@ class FalyxParser:
base_flag = flags[0] base_flag = flags[0]
negated_flag = f"--no-{base_flag.lstrip('-')}" negated_flag = f"--no-{base_flag.lstrip('-')}"
argument = Option( option = Option(
flags=flags, flags=flags,
dest=dest, dest=dest,
action=OptionAction.STORE_BOOL_OPTIONAL, action=OptionAction.STORE_BOOL_OPTIONAL,
@@ -277,7 +206,7 @@ class FalyxParser:
help=help, help=help,
) )
negated_argument = Option( negated_option = Option(
flags=(negated_flag,), flags=(negated_flag,),
dest=dest, dest=dest,
action=OptionAction.STORE_BOOL_OPTIONAL, action=OptionAction.STORE_BOOL_OPTIONAL,
@@ -286,17 +215,19 @@ class FalyxParser:
help=help, help=help,
) )
self._register_option(argument) self._register_option(option)
self._register_option(negated_argument, bypass_validation=True) self._register_option(negated_option, bypass_validation=True)
return option
def _register_option(self, option: Option, bypass_validation: bool = False) -> None: def _register_option(self, option: Option, bypass_validation: bool = False) -> None:
self._dest_set.add(option.dest) self._dest_set.add(option.dest)
self._options.append(option) self._options.append(option)
self._last_option_states[option.dest] = OptionState(option)
for flag in option.flags: 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] existing = self._options_by_dest[flag]
raise FalyxOptionError( 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 self._options_by_dest[flag] = option
@@ -319,7 +250,11 @@ class FalyxParser:
if flag in self._options_by_dest: if flag in self._options_by_dest:
existing = self._options_by_dest[flag] existing = self._options_by_dest[flag]
raise FalyxOptionError( 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: 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}'.") raise FalyxOptionError(f"default value cannot be set for action '{action}'.")
return default return default
def _validate_default_type( def _normalize_default_type(
self, self,
default: Any, default: Any,
expected_type: Any, expected_type: Any,
dest: str, dest: str,
) -> None: ) -> Any:
if default is None: if default is None:
return None return None
try: try:
coerce_value(default, expected_type) return coerce_value(default, expected_type)
except Exception as error: except Exception as error:
type_name = get_type_name(expected_type) type_name = get_type_name(expected_type)
raise FalyxOptionError( raise FalyxOptionError(
@@ -408,7 +343,7 @@ class FalyxParser:
def _normalize_choices( def _normalize_choices(
self, self,
choices: list[str] | None, choices: list[str] | None,
expected_type: type, expected_type: Callable[[Any], Any],
action: OptionAction, action: OptionAction,
) -> list[Any]: ) -> list[Any]:
if choices is None: if choices is None:
@@ -430,27 +365,28 @@ class FalyxParser:
raise FalyxOptionError( raise FalyxOptionError(
"choices must be iterable (like list, tuple, or set)" "choices must be iterable (like list, tuple, or set)"
) from error ) from error
normalized: list[Any] = []
for choice in choices: for choice in choices:
try: try:
coerce_value(choice, expected_type) normalized.append(coerce_value(choice, expected_type))
except Exception as error: except Exception as error:
type_name = get_type_name(expected_type) type_name = get_type_name(expected_type)
raise FalyxOptionError( raise FalyxOptionError(
f"invalid choice {choice!r} cannot be coerced to {type_name} error: {error}" f"invalid choice {choice!r} cannot be coerced to {type_name} error: {error}"
) from error ) from error
return choices return normalized
def add_option( def add_option(
self, self,
flags: tuple[str, ...], *flags: str,
dest: str,
action: str | OptionAction = "store", action: str | OptionAction = "store",
type: type = str,
default: Any = None, default: Any = None,
type: Callable[[Any], Any] = str,
choices: list[str] | None = None, choices: list[str] | None = None,
help: str = "", help: str = "",
dest: str | None = None,
suggestions: list[str] | None = None, suggestions: list[str] | None = None,
) -> None: ) -> Option:
self._validate_flags(flags) self._validate_flags(flags)
dest = self._get_dest_from_flags(flags, dest) dest = self._get_dest_from_flags(flags, dest)
if dest in self.RESERVED_DESTS: if dest in self.RESERVED_DESTS:
@@ -461,7 +397,10 @@ class FalyxParser:
raise FalyxOptionError(f"duplicate option dest '{dest}'") raise FalyxOptionError(f"duplicate option dest '{dest}'")
action = self._validate_action(action) action = self._validate_action(action)
default = self._resolve_default(default, 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) choices = self._normalize_choices(choices, type, action)
if default is not None and choices and default not in choices: if default is not None and choices and default not in choices:
choices_str = ", ".join((str(choice) for choice 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") raise FalyxOptionError("suggestions must be a list of strings")
if action is OptionAction.STORE_BOOL_OPTIONAL: if action is OptionAction.STORE_BOOL_OPTIONAL:
self._register_store_bool_optional(flags, dest, help) return self._register_store_bool_optional(flags, dest, help)
return None
option = Option( option = Option(
flags=flags, flags=flags,
dest=dest, dest=dest,
@@ -489,16 +427,86 @@ class FalyxParser:
suggestions=suggestions, suggestions=suggestions,
) )
self._register_option(option) self._register_option(option)
return option
def apply_to_options( def _filter_suggestions(
self, self,
parse_result: ParseResult, suggestion: str,
options: OptionsManager, prefix: str,
) -> None: cursor_at_end_of_token: bool,
for dest, value in parse_result.options.items(): ) -> bool:
options.set(dest, value, namespace_name=self_flx.namespace_name) if cursor_at_end_of_token:
for dest, value in parse_result.root_options.items(): return True
options.set(dest, value, namespace_name="root") 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: def _can_bundle_option(self, option: Option) -> bool:
return option.action in { return option.action in {
@@ -510,7 +518,7 @@ class FalyxParser:
} }
def _resolve_posix_bundling(self, tokens: list[str]) -> list[str]: 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] = [] expanded: list[str] = []
for token in tokens: for token in tokens:
if not token.startswith("-") or token.startswith("--") or len(token) <= 2: if not token.startswith("-") or token.startswith("--") or len(token) <= 2:
@@ -552,30 +560,37 @@ class FalyxParser:
argv: list[str], argv: list[str],
index: int, index: int,
values: dict[str, Any], values: dict[str, Any],
option_states: dict[str, OptionState],
) -> int: ) -> int:
match option.action: match option.action:
case OptionAction.STORE_TRUE: case OptionAction.STORE_TRUE:
values[option.dest] = True values[option.dest] = True
option_states[option.dest].set_consumed()
return index + 1 return index + 1
case OptionAction.STORE_FALSE: case OptionAction.STORE_FALSE:
values[option.dest] = False values[option.dest] = False
option_states[option.dest].set_consumed()
return index + 1 return index + 1
case OptionAction.STORE_BOOL_OPTIONAL: 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 return index + 1
case OptionAction.COUNT: case OptionAction.COUNT:
values[option.dest] = int(values.get(option.dest) or 0) + 1 values[option.dest] = int(values.get(option.dest) or 0) + 1
option_states[option.dest].set_consumed()
return index + 1 return index + 1
case OptionAction.HELP: case OptionAction.HELP:
values[option.dest] = True values[option.dest] = True
option_states[option.dest].set_consumed()
return index + 1 return index + 1
case OptionAction.TLDR: case OptionAction.TLDR:
values[option.dest] = True values[option.dest] = True
option_states[option.dest].set_consumed()
return index + 1 return index + 1
case OptionAction.STORE: case OptionAction.STORE:
@@ -598,6 +613,7 @@ class FalyxParser:
) )
values[option.dest] = value values[option.dest] = value
option_states[option.dest].set_consumed()
return index + 2 return index + 2
raise FalyxOptionError(f"unsupported option action: {option.action}") raise FalyxOptionError(f"unsupported option action: {option.action}")
@@ -606,19 +622,17 @@ class FalyxParser:
self, self,
argv: list[str] | None = None, argv: list[str] | None = None,
) -> ParseResult: ) -> ParseResult:
option_states = {option.dest: OptionState(option) for option in self._options}
self._last_option_states = option_states
raw_argv = argv or [] raw_argv = argv or []
arguments = self._resolve_posix_bundling(raw_argv) 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 index = 0
while index < len(arguments): while index < len(arguments):
token = arguments[index] token = arguments[index]
# Explicit option terminator. Everything after belongs to routing/command.
if token == "--":
index += 1
break
# First non-option is the route boundary. # First non-option is the route boundary.
if not token.startswith("-"): if not token.startswith("-"):
break break
@@ -631,20 +645,33 @@ class FalyxParser:
f"unknown option '{token}' for '{self._flx.program or self._flx.title}'" f"unknown option '{token}' for '{self._flx.program or self._flx.title}'"
) )
target_values = root_values if option.scope == OptionScope.ROOT else values target_values = (
index = self._consume_option(option, arguments, index, 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:] 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( return ParseResult(
mode=FalyxMode.HELP if help_requested else FalyxMode.COMMAND, mode=FalyxMode.HELP if help_requested else FalyxMode.COMMAND,
raw_argv=raw_argv, raw_argv=raw_argv,
options=values, root_defaults=root_defaults,
root_options=root_values, root_options=root_options,
namespace_defaults=namespace_defaults,
namespace_options=namespace_options,
remaining_argv=remaining_argv, remaining_argv=remaining_argv,
help=values.get("help", False), help=namespace_options.get("help", False),
tldr=values.get("tldr", False), tldr=namespace_options.get("tldr", False),
current_head=remaining_argv[0] if remaining_argv else "", current_head=remaining_argv[0] if remaining_argv else "",
) )

View File

@@ -46,7 +46,15 @@ class ArgumentGroup:
name: str name: str
description: 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) @dataclass(slots=True)
@@ -73,4 +81,13 @@ class MutuallyExclusiveGroup:
name: str name: str
required: bool = False required: bool = False
description: str = "" description: str = ""
dests: list[str] = field(default_factory=list) dests: set[str] = field(default_factory=set)
def copy(self) -> MutuallyExclusiveGroup:
"""Create a copy of this MutuallyExclusiveGroup."""
return MutuallyExclusiveGroup(
name=self.name,
required=self.required,
description=self.description,
dests=set(self.dests),
)

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

@@ -0,0 +1,41 @@
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Any
from falyx.parser.option_action import OptionAction
class OptionScope(Enum):
ROOT = "root"
NAMESPACE = "namespace"
@classmethod
def _missing_(cls, value: object) -> OptionScope:
if not isinstance(value, str):
raise ValueError(f"Invalid {cls.__name__}: {value!r}")
normalized = value.strip().lower()
for member in cls:
if member.value == normalized:
return member
valid = ", ".join(member.value for member in cls)
raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}")
@dataclass(slots=True)
class Option:
flags: tuple[str, ...]
dest: str
action: OptionAction = OptionAction.STORE
type: Any = str
default: Any = None
choices: list[str] | None = None
help: str = ""
suggestions: list[str] | None = None
scope: OptionScope = OptionScope.NAMESPACE
def format_for_help(self) -> str:
"""Return a formatted string of the option's flags for help output."""
return ", ".join(self.flags)

View File

@@ -0,0 +1,44 @@
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
from __future__ import annotations
from enum import Enum
class OptionAction(Enum):
STORE = "store"
STORE_TRUE = "store_true"
STORE_FALSE = "store_false"
STORE_BOOL_OPTIONAL = "store_bool_optional"
COUNT = "count"
HELP = "help"
TLDR = "tldr"
@classmethod
def choices(cls) -> list[OptionAction]:
"""Return a list of all option actions."""
return list(cls)
@classmethod
def _get_alias(cls, value: str) -> str:
aliases = {
"optional": "store_bool_optional",
"true": "store_true",
"false": "store_false",
}
return aliases.get(value, value)
@classmethod
def _missing_(cls, value: object) -> OptionAction:
if not isinstance(value, str):
raise ValueError(f"Invalid {cls.__name__}: {value!r}")
normalized = value.strip().lower()
alias = cls._get_alias(normalized)
for member in cls:
if member.value == alias:
return member
valid = ", ".join(member.value for member in cls)
raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}")
def __str__(self) -> str:
"""Return the string representation of the option action."""
return self.value

View File

@@ -38,9 +38,11 @@ class ParseResult:
Attributes: Attributes:
mode: Top-level runtime mode selected from the root parse. mode: Top-level runtime mode selected from the root parse.
raw_argv: Original argv passed into the root parser. 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 root_options: Dictionary of parsed root-level options that should be
applied at the root level for all namespaces. 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 remaining_argv: Unconsumed argv that should be forwarded to routed
command resolution. command resolution.
current_head: The current head token being processed (for error reporting). current_head: The current head token being processed (for error reporting).
@@ -53,12 +55,11 @@ class ParseResult:
mode: FalyxMode mode: FalyxMode
raw_argv: list[str] = field(default_factory=list) 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) 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) remaining_argv: list[str] = field(default_factory=list)
current_head: str = "" current_head: str = ""
help: bool = False help: bool = False
tldr: bool = False tldr: bool = False
verbose: bool = False
debug_hooks: bool = False
never_prompt: bool = False

View File

@@ -15,21 +15,16 @@ Contents:
These tools support richer expressiveness and user-friendly ergonomics in These tools support richer expressiveness and user-friendly ergonomics in
Falyx's declarative command-line interfaces. Falyx's declarative command-line interfaces.
""" """
from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, TypeAlias from typing import Any, TypeAlias
from falyx.parser.argument import Argument from falyx.parser.argument import Argument
from falyx.parser.option import Option
@dataclass class StateMixin:
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
def set_consumed(self, position: int | None = None) -> None: def set_consumed(self, position: int | None = None) -> None:
"""Mark this argument as consumed, optionally setting the position.""" """Mark this argument as consumed, optionally setting the position."""
self.consumed = True self.consumed = True
@@ -41,6 +36,26 @@ class ArgumentState:
self.consumed_position = None 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) @dataclass(frozen=True)
class TLDRExample: class TLDRExample:
"""Represents a usage example for TLDR output.""" """Represents a usage example for TLDR output."""
@@ -48,6 +63,13 @@ class TLDRExample:
usage: str usage: str
description: 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] TLDRInput: TypeAlias = TLDRExample | tuple[str, str]
@@ -60,6 +82,14 @@ class FalyxTLDRExample:
usage: str usage: str
description: 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] FalyxTLDRInput: TypeAlias = FalyxTLDRExample | tuple[str, str, str]

View File

@@ -12,6 +12,7 @@ Functions:
- same_argument_definitions: Check if multiple callables share the same argument structure. - same_argument_definitions: Check if multiple callables share the same argument structure.
""" """
import types import types
from collections.abc import Callable
from datetime import datetime from datetime import datetime
from enum import EnumMeta from enum import EnumMeta
from typing import Any, Literal, Union, get_args, get_origin 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 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. """Attempt to convert a string to the given target type.
Handles complex typing constructs such as Union, Literal, Enum, and datetime. Handles complex typing constructs such as Union, Literal, Enum, and datetime.
Args: Args:
value (str): The input string to convert. value (str): The input string to convert.
target_type (type): The desired type. target_type (Callable[[Any], Any]): The desired type.
Returns: Returns:
Any: The coerced value. Any: The coerced value.

View File

@@ -44,6 +44,7 @@ def should_prompt_user(
*, *,
confirm: bool, confirm: bool,
options: OptionsManager, options: OptionsManager,
action_never_prompt: bool | None = None,
namespace: str = "root", namespace: str = "root",
override_namespace: str = "execution", override_namespace: str = "execution",
) -> bool: ) -> bool:
@@ -57,27 +58,30 @@ def should_prompt_user(
Args: Args:
confirm (bool): The initial confirmation flag (e.g., from a command argument). confirm (bool): The initial confirmation flag (e.g., from a command argument).
options (OptionsManager): The options manager to check for override flags. options (OptionsManager): The options manager to check for override flags.
namespace (str): The primary namespace to check for options (default: "root"). namespace (str): The secondary namespace to check for options (default: "root").
override_namespace (str): The secondary namespace for overrides (default: "execution"). override_namespace (str): The primary namespace for overrides (default: "execution").
Returns: Returns:
bool: True if the user should be prompted, False if confirmation can be bypassed. 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) never_prompt = options.get("never_prompt", None, override_namespace)
if never_prompt is None: if never_prompt is None:
never_prompt = options.get("never_prompt", False, namespace) never_prompt = options.get("never_prompt", False, namespace)
if never_prompt:
return False
force_confirm = options.get("force_confirm", None, override_namespace) force_confirm = options.get("force_confirm", None, override_namespace)
if force_confirm is None: if force_confirm is None:
force_confirm = options.get("force_confirm", False, namespace) 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 return confirm or force_confirm

View File

@@ -23,7 +23,7 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any
from falyx.context import InvocationContext from falyx.context import InvocationContext
from falyx.namespace import FalyxNamespace from falyx.namespace import FalyxNamespace
@@ -82,6 +82,8 @@ class RouteResult:
generating suggestions. generating suggestions.
suggestions: Suggested entry names for unresolved input. suggestions: Suggested entry names for unresolved input.
is_preview: Whether the routed invocation is in preview mode. 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 kind: RouteKind
@@ -93,3 +95,7 @@ class RouteResult:
current_head: str = "" current_head: str = ""
suggestions: list[str] = field(default_factory=list) suggestions: list[str] = field(default_factory=list)
is_preview: bool = False is_preview: bool = False
root_defaults: dict[str, Any] = field(default_factory=dict)
root_overrides: dict[str, Any] = field(default_factory=dict)
namespace_defaults: dict[str, Any] = field(default_factory=dict)
namespace_overrides: dict[str, Any] = field(default_factory=dict)

View File

@@ -11,6 +11,8 @@ It supports:
Used by `SelectionAction` and other prompt-driven workflows within Falyx. Used by `SelectionAction` and other prompt-driven workflows within Falyx.
""" """
from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Callable, KeysView, Sequence from typing import Any, Callable, KeysView, Sequence
@@ -43,6 +45,14 @@ class SelectionOption:
key = escape(f"[{key}]") key = escape(f"[{key}]")
return f"[{OneColors.WHITE}]{key}[/] [{self.style}]{self.description}[/]" 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): class SelectionOptionMap(CaseInsensitiveDict):
"""Manages selection options including validation and reserved key protection.""" """Manages selection options including validation and reserved key protection."""
@@ -97,6 +107,13 @@ class SelectionOptionMap(CaseInsensitiveDict):
continue continue
yield k, v 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( def render_table_base(
title: str, title: str,

View File

@@ -91,6 +91,9 @@ class CaseInsensitiveDict(dict):
def __getitem__(self, key): def __getitem__(self, key):
return super().__getitem__(self._normalize_key(key)) return super().__getitem__(self._normalize_key(key))
def __delitem__(self, key):
super().__delitem__(self._normalize_key(key))
def __contains__(self, key): def __contains__(self, key):
return super().__contains__(self._normalize_key(key)) return super().__contains__(self._normalize_key(key))

View File

@@ -0,0 +1,334 @@
import pytest
from falyx.action.action import Action
from falyx.action.action_group import ActionGroup
from falyx.action.chained_action import ChainedAction
from falyx.action.http_action import HTTPAction
from falyx.action.menu_action import MenuAction
from falyx.action.process_action import ProcessAction
from falyx.hook_manager import HookType
from falyx.menu import MenuOption, MenuOptionMap
from falyx.retry import RetryHandler, RetryPolicy
def _retry_hooks(action) -> list:
return [
hook
for hook in action.hooks._hooks[HookType.ON_ERROR]
if isinstance(getattr(hook, "__self__", None), RetryHandler)
]
def _non_retry_error_hooks(action) -> list:
return [
hook
for hook in action.hooks._hooks[HookType.ON_ERROR]
if not isinstance(getattr(hook, "__self__", None), RetryHandler)
]
def _before_hooks(action) -> list:
return list(action.hooks._hooks[HookType.BEFORE])
def test_action_group_clone_recursively_isolates_nested_action_graph():
nested_chain = ChainedAction(
name="nested-chain",
actions=[
Action("step-two", lambda: "two"),
Action("step-three", lambda: "three"),
],
)
original = ActionGroup(
name="group",
actions=[
Action("step-one", lambda: "one"),
nested_chain,
],
)
cloned = original.clone()
assert cloned is not original
assert cloned.actions is not original.actions
assert len(cloned.actions) == len(original.actions)
# Top-level children are cloned.
assert cloned.actions[0] is not original.actions[0]
assert cloned.actions[1] is not original.actions[1]
# Nested action graph is also cloned.
assert isinstance(cloned.actions[1], ChainedAction)
assert cloned.actions[1].actions is not original.actions[1].actions
for cloned_child, original_child in zip(
cloned.actions[1].actions,
original.actions[1].actions,
strict=True,
):
assert cloned_child is not original_child
assert cloned_child.name == original_child.name
# Mutating the clone does not mutate the original.
cloned.actions.append(Action("step-four", lambda: "four"))
assert len(cloned.actions) == 3
assert len(original.actions) == 2
cloned.actions[1].actions.append(Action("step-five", lambda: "five"))
assert len(cloned.actions[1].actions) == 3
assert len(original.actions[1].actions) == 2
def test_menu_action_clone_copies_menu_option_map_and_clones_contained_actions():
menu_options = MenuOptionMap(disable_reserved=True)
menu_options["A"] = MenuOption(
description="Alpha",
action=Action("alpha-action", lambda: "alpha"),
)
original = MenuAction(
name="main-menu",
menu_options=menu_options,
title="Main Menu",
)
cloned = original.clone()
assert cloned is not original
assert cloned.menu_options is not original.menu_options
assert cloned.menu_options["A"] is not original.menu_options["A"]
assert cloned.menu_options["A"].description == original.menu_options["A"].description
# Contained action should also be cloned.
assert cloned.menu_options["A"].action is not original.menu_options["A"].action
assert cloned.menu_options["A"].action.name == original.menu_options["A"].action.name
# Mutating the clone should not affect the original.
cloned.menu_options["A"].description = "Changed"
assert original.menu_options["A"].description == "Alpha"
cloned.menu_options["B"] = MenuOption(
description="Beta",
action=Action("beta-action", lambda: "beta"),
)
assert "B" in cloned.menu_options
assert "B" not in original.menu_options
def test_process_action_clone_does_not_reuse_runtime_only_executor_state():
original = ProcessAction(
name="proc",
action=lambda x: x + 1,
args=(1,),
kwargs={"y": 2},
)
original.executor = object()
cloned = original.clone()
assert cloned is not original
assert cloned.hooks is not original.hooks
assert cloned.args == original.args
assert cloned.kwargs == original.kwargs
assert cloned.executor is not original.executor
def test_http_action_clone_preserves_retry_policy_without_duplicating_spinner_hooks():
retry_policy = RetryPolicy(max_retries=3, delay=1.5, backoff=2.0)
retry_policy.enable_policy()
original = HTTPAction(
name="get-users",
method="GET",
url="https://example.com/api/users",
headers={"Authorization": "Bearer token"},
params={"page": 1},
retry_policy=retry_policy,
spinner=True,
)
before_count = len(original.hooks._hooks[HookType.BEFORE])
teardown_count = len(original.hooks._hooks[HookType.ON_TEARDOWN])
error_count = len(original.hooks._hooks[HookType.ON_ERROR])
cloned = original.clone()
assert cloned is not original
assert cloned.hooks is not original.hooks
assert cloned.retry_policy is not original.retry_policy
assert cloned.retry_policy.enabled is original.retry_policy.enabled
assert cloned.retry_policy.max_retries == original.retry_policy.max_retries
assert cloned.retry_policy.delay == original.retry_policy.delay
assert cloned.retry_policy.backoff == original.retry_policy.backoff
assert len(cloned.hooks._hooks[HookType.BEFORE]) == before_count
assert len(cloned.hooks._hooks[HookType.ON_TEARDOWN]) == teardown_count
assert len(cloned.hooks._hooks[HookType.ON_ERROR]) == error_count
@pytest.mark.asyncio
async def test_action_clone_registers_exactly_one_retry_hook():
async def flaky():
return "ok"
policy = RetryPolicy(max_retries=2, delay=0.1, backoff=2.0)
policy.enable_policy()
original = Action(
"flaky",
flaky,
retry_policy=policy,
)
cloned = original.clone()
original_retry_hooks = _retry_hooks(original)
cloned_retry_hooks = _retry_hooks(cloned)
assert len(original_retry_hooks) == 1
assert len(cloned_retry_hooks) == 1
assert cloned_retry_hooks[0] is not original_retry_hooks[0]
assert getattr(cloned_retry_hooks[0], "__self__", None) is not getattr(
original_retry_hooks[0], "__self__", None
)
def test_action_clone_preserves_non_retry_hooks_without_duplication():
calls = []
async def custom_error_hook(context):
calls.append(context.name)
original = Action("demo", lambda: "ok")
original.hooks.register(HookType.BEFORE, lambda context: None)
original.hooks.register(HookType.ON_ERROR, custom_error_hook)
cloned = original.clone()
assert len(_before_hooks(cloned)) == len(_before_hooks(original))
assert len(_non_retry_error_hooks(cloned)) == len(_non_retry_error_hooks(original))
assert cloned.hooks is not original.hooks
def test_action_clone_copies_retry_policy_without_sharing_it():
policy = RetryPolicy(max_retries=2, delay=0.25, backoff=3.0)
policy.enable_policy()
original = Action(
"demo",
lambda: "ok",
retry_policy=policy,
)
cloned = original.clone()
assert cloned.retry_policy is not original.retry_policy
assert cloned.retry_policy.enabled is original.retry_policy.enabled
assert cloned.retry_policy.max_retries == original.retry_policy.max_retries
assert cloned.retry_policy.delay == original.retry_policy.delay
assert cloned.retry_policy.backoff == original.retry_policy.backoff
cloned.retry_policy.max_retries = 9
assert original.retry_policy.max_retries == 2
@pytest.mark.asyncio
async def test_action_clone_retry_behavior_still_works_independently():
state = {"original": 0, "clone": 0}
async def flaky_original():
if state["original"] == 0:
state["original"] += 1
raise RuntimeError("boom")
return "original-ok"
async def flaky_clone():
if state["clone"] == 0:
state["clone"] += 1
raise RuntimeError("boom")
return "clone-ok"
policy = RetryPolicy(max_retries=1, delay=0.0, backoff=1.0)
policy.enable_policy()
original = Action("orig", flaky_original, retry_policy=policy)
cloned = original.clone()
cloned.action = flaky_clone
original_result = await original()
cloned_result = await cloned()
assert original_result == "original-ok"
assert cloned_result == "clone-ok"
assert state["original"] == 1
assert state["clone"] == 1
def test_http_action_clone_registers_exactly_one_retry_hook():
policy = RetryPolicy(max_retries=2, delay=0.1, backoff=2.0)
policy.enable_policy()
original = HTTPAction(
name="get-users",
method="GET",
url="https://example.com/api/users",
retry_policy=policy,
spinner=True,
)
cloned = original.clone()
original_retry_hooks = _retry_hooks(original)
cloned_retry_hooks = _retry_hooks(cloned)
assert len(original_retry_hooks) == 1
assert len(cloned_retry_hooks) == 1
assert cloned_retry_hooks[0] is not original_retry_hooks[0]
def test_http_action_clone_copies_retry_policy_without_sharing_it():
policy = RetryPolicy(max_retries=3, delay=1.5, backoff=2.0)
policy.enable_policy()
original = HTTPAction(
name="get-users",
method="GET",
url="https://example.com/api/users",
retry_policy=policy,
)
cloned = original.clone()
assert cloned.retry_policy is not original.retry_policy
assert cloned.retry_policy.enabled is original.retry_policy.enabled
assert cloned.retry_policy.max_retries == original.retry_policy.max_retries
assert cloned.retry_policy.delay == original.retry_policy.delay
assert cloned.retry_policy.backoff == original.retry_policy.backoff
def test_http_action_clone_does_not_duplicate_spinner_hooks():
policy = RetryPolicy(max_retries=1, delay=0.0, backoff=1.0)
policy.enable_policy()
original = HTTPAction(
name="get-users",
method="GET",
url="https://example.com/api/users",
retry_policy=policy,
spinner=True,
)
cloned = original.clone()
assert len(cloned.hooks._hooks[HookType.BEFORE]) == len(
original.hooks._hooks[HookType.BEFORE]
)
assert len(cloned.hooks._hooks[HookType.ON_TEARDOWN]) == len(
original.hooks._hooks[HookType.ON_TEARDOWN]
)

View File

@@ -0,0 +1,430 @@
from __future__ import annotations
import csv
import json
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any
import pytest
import toml
import yaml
from rich.tree import Tree
from falyx.action.action_types import FileType
from falyx.action.save_file_action import SaveFileAction
from falyx.hook_manager import HookType
class CaptureConsole:
def __init__(self) -> None:
self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
def print(self, *args: Any, **kwargs: Any) -> None:
self.printed.append((args, kwargs))
def make_action(file_path: Path | str | None, **overrides: Any) -> SaveFileAction:
defaults: dict[str, Any] = {
"name": "SaveOutput",
"file_path": file_path,
}
defaults.update(overrides)
return SaveFileAction(**defaults)
def register_lifecycle_hooks(action: SaveFileAction) -> list[tuple[HookType, Any]]:
calls: list[tuple[HookType, Any]] = []
def make_hook(hook_type: HookType):
def hook(context: Any) -> None:
calls.append((hook_type, context))
return hook
for hook_type in HookType:
action.hooks.register(hook_type, make_hook(hook_type))
return calls
def hook_types(calls: list[tuple[HookType, Any]]) -> list[HookType]:
return [hook_type for hook_type, _ in calls]
def test_init_normalizes_configuration_and_string_file_type(tmp_path: Path) -> None:
target = tmp_path / "output.json"
action = SaveFileAction(
name="SaveJson",
file_path=str(target),
file_type="json",
mode="a",
encoding="utf-8",
data={"name": "falyx"},
overwrite=False,
create_dirs=False,
inject_last_result=True,
inject_into="payload",
never_prompt=True,
)
assert action.name == "SaveJson"
assert action.file_path == target
assert action.file_type == FileType.JSON
assert action.mode == "a"
assert action.encoding == "utf-8"
assert action.data == {"name": "falyx"}
assert action.overwrite is False
assert action.create_dirs is False
assert action.inject_last_result is True
assert action.inject_into == "payload"
assert action.local_never_prompt is True
assert "SaveFileAction" in str(action)
assert "output.json" in str(action)
def test_file_path_property_coerces_string_path_and_none(tmp_path: Path) -> None:
action = make_action(None)
assert action.file_path is None
target = tmp_path / "later.txt"
action.file_path = str(target)
assert action.file_path == target
action.file_path = target
assert action.file_path == target
def test_file_path_rejects_unsupported_values(tmp_path: Path) -> None:
action = make_action(tmp_path / "out.txt")
with pytest.raises(TypeError, match="file_path must be a string or Path object"):
action.file_path = 123 # type: ignore[assignment]
def test_get_infer_target_disables_signature_inference(tmp_path: Path) -> None:
action = make_action(tmp_path / "out.txt")
assert action.get_infer_target() == (None, None)
def test_dict_to_xml_serializes_nested_dicts_lists_and_scalars(tmp_path: Path) -> None:
action = make_action(tmp_path / "out.xml", file_type=FileType.XML)
root = ET.Element("root")
action._dict_to_xml(
{
"name": "falyx",
"metadata": {"version": "0.2.0"},
"tags": ["cli", "framework"],
"commands": [{"name": "run"}, {"name": "help"}],
},
root,
)
assert root.findtext("name") == "falyx"
assert root.find("metadata") is not None
assert root.find("metadata/version") is not None
assert root.findtext("metadata/version") == "0.2.0"
assert [element.text for element in root.findall("tags")] == ["cli", "framework"]
assert [element.findtext("name") for element in root.findall("commands")] == [
"run",
"help",
]
@pytest.mark.asyncio
async def test_save_file_requires_file_path_before_saving() -> None:
action = make_action(None, data="hello")
with pytest.raises(ValueError, match="file_path must be set"):
await action.save_file("hello")
@pytest.mark.asyncio
async def test_save_file_refuses_to_overwrite_existing_file_when_disabled(
tmp_path: Path,
) -> None:
target = tmp_path / "existing.txt"
target.write_text("original", encoding="UTF-8")
action = make_action(target, overwrite=False)
with pytest.raises(FileExistsError, match="File already exists"):
await action.save_file("replacement")
assert target.read_text(encoding="UTF-8") == "original"
@pytest.mark.asyncio
async def test_save_file_requires_parent_directory_when_create_dirs_is_disabled(
tmp_path: Path,
) -> None:
target = tmp_path / "missing" / "out.txt"
action = make_action(target, create_dirs=False)
with pytest.raises(FileNotFoundError, match="Directory does not exist"):
await action.save_file("hello")
@pytest.mark.asyncio
async def test_save_file_creates_missing_parent_directories(tmp_path: Path) -> None:
target = tmp_path / "nested" / "out.txt"
action = make_action(target, file_type=FileType.TEXT, create_dirs=True)
await action.save_file("hello")
assert target.read_text(encoding="UTF-8") == "hello"
@pytest.mark.asyncio
@pytest.mark.parametrize(
("file_type", "filename", "data"),
[
(FileType.TEXT, "note.txt", "hello"),
(FileType.JSON, "data.json", {"name": "falyx", "count": 2}),
(FileType.YAML, "data.yaml", {"name": "falyx", "enabled": True}),
(FileType.TOML, "data.toml", {"name": "falyx", "count": 2}),
(FileType.CSV, "rows.csv", [["name", "count"], ["falyx", "2"]]),
(FileType.TSV, "rows.tsv", [["name", "count"], ["falyx", "2"]]),
(
FileType.XML,
"data.xml",
{
"name": "falyx",
"metadata": {"version": "0.2.0"},
"tags": ["cli", "framework"],
},
),
],
)
async def test_save_file_writes_supported_file_types(
tmp_path: Path,
file_type: FileType,
filename: str,
data: Any,
) -> None:
target = tmp_path / filename
action = make_action(target, file_type=file_type)
await action.save_file(data)
if file_type == FileType.TEXT:
assert target.read_text(encoding="UTF-8") == data
elif file_type == FileType.JSON:
assert json.loads(target.read_text(encoding="UTF-8")) == data
elif file_type == FileType.YAML:
assert yaml.safe_load(target.read_text(encoding="UTF-8")) == data
elif file_type == FileType.TOML:
assert toml.loads(target.read_text(encoding="UTF-8")) == data
elif file_type == FileType.CSV:
with target.open(newline="", encoding="UTF-8") as file:
assert list(csv.reader(file)) == data
elif file_type == FileType.TSV:
with target.open(newline="", encoding="UTF-8") as file:
assert list(csv.reader(file, delimiter="\t")) == data
elif file_type == FileType.XML:
root = ET.parse(target).getroot()
assert root.tag == "root"
assert root.findtext("name") == "falyx"
assert root.findtext("metadata/version") == "0.2.0"
assert [element.text for element in root.findall("tags")] == [
"cli",
"framework",
]
@pytest.mark.asyncio
@pytest.mark.parametrize("file_type", [FileType.CSV, FileType.TSV])
@pytest.mark.parametrize(
"data",
[
{"name": "falyx"},
["name", "count"],
[["name", "count"], "not-a-row"],
],
)
async def test_save_file_requires_list_of_lists_for_delimited_formats(
tmp_path: Path,
file_type: FileType,
data: Any,
) -> None:
target = tmp_path / "rows.data"
action = make_action(target, file_type=file_type)
with pytest.raises(ValueError, match="requires a list of lists"):
await action.save_file(data)
@pytest.mark.asyncio
async def test_save_file_requires_dict_for_xml(tmp_path: Path) -> None:
target = tmp_path / "data.xml"
action = make_action(target, file_type=FileType.XML)
with pytest.raises(
ValueError, match="XML file type requires data to be a dictionary"
):
await action.save_file(["not", "a", "dict"])
@pytest.mark.asyncio
async def test_save_file_raises_for_unsupported_internal_file_type(
tmp_path: Path,
) -> None:
target = tmp_path / "data.out"
action = make_action(target, file_type=FileType.TEXT)
action._file_type = object() # Force the defensive unsupported-type branch.
with pytest.raises(ValueError, match="Unsupported file type"):
await action.save_file("hello")
@pytest.mark.asyncio
async def test_save_file_reraises_write_errors(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
target = tmp_path / "out.txt"
action = make_action(target, file_type=FileType.TEXT)
def fake_write_text(self: Path, data: str, *, encoding: str | None = None) -> int:
raise OSError("disk is unavailable")
monkeypatch.setattr(Path, "write_text", fake_write_text)
with pytest.raises(OSError, match="disk is unavailable"):
await action.save_file("hello")
@pytest.mark.asyncio
async def test_run_saves_configured_data_and_triggers_success_lifecycle(
tmp_path: Path,
) -> None:
target = tmp_path / "out.txt"
action = make_action(target, file_type=FileType.TEXT, data="hello")
calls = register_lifecycle_hooks(action)
result = await action("positional", ignored="kwarg")
assert result == str(target)
assert target.read_text(encoding="UTF-8") == "hello"
assert hook_types(calls) == [
HookType.BEFORE,
HookType.ON_SUCCESS,
HookType.AFTER,
HookType.ON_TEARDOWN,
]
assert calls[0][1].args == ("positional",)
assert calls[0][1].kwargs == {"ignored": "kwarg"}
assert calls[0][1].action is action
@pytest.mark.asyncio
async def test_run_uses_data_from_kwargs_when_no_static_data_is_configured(
tmp_path: Path,
) -> None:
target = tmp_path / "out.txt"
action = make_action(target, file_type=FileType.TEXT, data=None)
result = await action(data="from kwargs")
assert result == str(target)
assert target.read_text(encoding="UTF-8") == "from kwargs"
@pytest.mark.asyncio
async def test_run_triggers_error_lifecycle_and_reraises(tmp_path: Path) -> None:
action = make_action(None, data="hello")
calls = register_lifecycle_hooks(action)
with pytest.raises(ValueError, match="file_path must be set"):
await action()
assert hook_types(calls) == [
HookType.BEFORE,
HookType.ON_ERROR,
HookType.AFTER,
HookType.ON_TEARDOWN,
]
assert isinstance(calls[1][1].exception, ValueError)
@pytest.mark.asyncio
async def test_preview_prints_tree_for_existing_file_when_overwrite_enabled(
tmp_path: Path,
) -> None:
target = tmp_path / "out.txt"
target.write_text("existing", encoding="UTF-8")
action = make_action(target, file_type=FileType.TEXT, overwrite=True)
action.console = CaptureConsole()
await action.preview()
assert len(action.console.printed) == 1
printed_tree = action.console.printed[0][0][0]
assert isinstance(printed_tree, Tree)
@pytest.mark.asyncio
async def test_preview_prints_tree_for_existing_file_when_overwrite_disabled(
tmp_path: Path,
) -> None:
target = tmp_path / "out.txt"
target.write_text("existing", encoding="UTF-8")
action = make_action(target, file_type=FileType.TEXT, overwrite=False)
action.console = CaptureConsole()
await action.preview()
assert len(action.console.printed) == 1
printed_tree = action.console.printed[0][0][0]
assert isinstance(printed_tree, Tree)
@pytest.mark.asyncio
async def test_preview_adds_to_existing_parent_without_printing(tmp_path: Path) -> None:
target = tmp_path / "out.txt"
action = make_action(target, file_type=FileType.JSON)
action.console = CaptureConsole()
parent = Tree("root")
await action.preview(parent=parent)
assert action.console.printed == []
assert len(parent.children) == 1
def test_clone_preserves_configuration_but_returns_distinct_action(
tmp_path: Path,
) -> None:
target = tmp_path / "out.json"
action = make_action(
target,
file_type=FileType.JSON,
mode="a",
encoding="utf-8",
data={"name": "falyx"},
overwrite=False,
create_dirs=False,
inject_last_result=True,
inject_into="payload",
never_prompt=True,
)
clone = action.clone()
assert clone is not action
assert clone.name == action.name
assert clone.file_path == action.file_path
assert clone.file_type == action.file_type
assert clone.mode == action.mode
assert clone.encoding == action.encoding
assert clone.data == action.data
assert clone.overwrite is action.overwrite
assert clone.create_dirs is action.create_dirs
assert clone.inject_last_result is action.inject_last_result
assert clone.inject_into == action.inject_into
assert clone.local_never_prompt is True

View File

@@ -1,7 +1,83 @@
import pytest from __future__ import annotations
from falyx.action import SelectionAction from typing import Any
from falyx.selection import SelectionOption
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 @pytest.mark.asyncio
@@ -285,3 +361,586 @@ async def test_selection_prompt_map_never_prompt_by_value_wildcard():
result = await action() result = await action()
assert result == ["Beta Service", "Alpha Service"] assert result == ["Beta Service", "Alpha Service"]
def test_init_normalizes_list_tuple_set_and_basic_configuration() -> None:
session = DummyPromptSession()
tuple_action = SelectionAction(
name="TupleChoice",
selections=("red", "blue"),
title="Colors",
columns=2,
prompt_message="[bold]Pick >[/] ",
default_selection="1",
number_selections=1,
separator=";",
allow_duplicates=True,
return_type="value",
prompt_session=session,
never_prompt=True,
show_table=False,
)
assert tuple_action.selections == ["red", "blue"]
assert tuple_action.return_type is SelectionReturnType.VALUE
assert tuple_action.title == "Colors"
assert tuple_action.columns == 2
assert tuple_action.default_selection == "1"
assert tuple_action.separator == ";"
assert tuple_action.allow_duplicates is True
assert tuple_action.prompt_session is session
assert tuple_action.local_never_prompt is True
assert tuple_action.show_table is False
set_action = make_action({"red", "blue"})
assert sorted(set_action.selections) == ["blue", "red"]
def test_init_converts_plain_dict_to_selection_option_map() -> None:
action = make_action({"dev": "Development", "prod": "Production"})
assert isinstance(action.selections, SelectionOptionMap)
assert list(action.selections) == ["0", "1"]
assert action.selections["0"] == SelectionOption("dev", "Development")
assert action.selections["1"] == SelectionOption("prod", "Production")
def test_init_preserves_selection_option_map_values() -> None:
action = make_action(
{
"D": SelectionOption("Development", "dev", style="green"),
"P": SelectionOption("Production", "prod", style="red"),
}
)
assert isinstance(action.selections, SelectionOptionMap)
assert action.selections["D"].description == "Development"
assert action.selections["P"].value == "prod"
@pytest.mark.parametrize("number_selections", [1, 2, "*"])
def test_number_selections_accepts_positive_ints_and_star(
number_selections: int | str,
) -> None:
action = make_action(number_selections=number_selections)
assert action.number_selections == number_selections
@pytest.mark.parametrize("number_selections", [0, -1, "many", object()])
def test_number_selections_rejects_invalid_values(number_selections: Any) -> None:
action = make_action()
with pytest.raises(ValueError, match="number_selections"):
action.number_selections = number_selections
@pytest.mark.parametrize(
("selections", "error_type", "match"),
[
({1: SelectionOption("One", 1)}, ValueError, "Invalid dictionary format"),
(123, TypeError, "selections"),
],
)
def test_selections_setter_rejects_invalid_inputs(
selections: Any,
error_type: type[BaseException],
match: str,
) -> None:
with pytest.raises(error_type, match=match):
make_action(selections)
def test_find_cancel_key_returns_numeric_gap_for_dict_and_next_index_for_list() -> None:
dict_action = make_action(
{
"0": SelectionOption("Zero", 0),
"2": SelectionOption("Two", 2),
}
)
list_action = make_action(["zero", "one"])
assert dict_action._find_cancel_key() == "1"
assert list_action._find_cancel_key() == "2"
def test_cancel_key_setter_rejects_non_string_values() -> None:
action = make_action()
with pytest.raises(TypeError, match="Cancel key must be a string"):
action.cancel_key = 1 # type: ignore[assignment]
def test_cancel_key_setter_rejects_existing_dict_key() -> None:
action = make_action({"A": SelectionOption("Alpha", "alpha")})
with pytest.raises(
ValueError, match="Cancel key cannot be one of the selection keys"
):
action.cancel_key = "A"
@pytest.mark.parametrize("cancel_key", ["x", "3"])
def test_cancel_key_setter_rejects_invalid_list_cancel_key(cancel_key: str) -> None:
action = make_action(["alpha", "beta"])
with pytest.raises(ValueError, match="cancel_key must be a digit"):
action.cancel_key = cancel_key
def test_cancel_formatter_marks_cancel_key_and_formats_regular_items() -> None:
action = make_action(["alpha", "beta"])
action.cancel_key = "2"
assert "Cancel" in action.cancel_formatter(2, "Cancel")
assert action.cancel_formatter(1, "beta").endswith("beta")
def test_get_infer_target_disables_signature_inference() -> None:
action = make_action()
assert action.get_infer_target() == (None, None)
@pytest.mark.parametrize(
("return_type", "keys", "expected"),
[
(SelectionReturnType.KEY, "0", "0"),
(SelectionReturnType.KEY, ["0", "2"], ["0", "2"]),
(SelectionReturnType.VALUE, "1", "prod"),
(SelectionReturnType.VALUE, ["0", "2"], ["dev", "stage"]),
(SelectionReturnType.DESCRIPTION, "0", "Development"),
(
SelectionReturnType.DESCRIPTION,
["0", "2"],
["Development", "Staging"],
),
(
SelectionReturnType.DESCRIPTION_VALUE,
"1",
{"Production": "prod"},
),
(
SelectionReturnType.DESCRIPTION_VALUE,
["0", "2"],
{"Development": "dev", "Staging": "stage"},
),
],
)
def test_get_result_from_keys_returns_configured_shape(
return_type: SelectionReturnType,
keys: str | list[str],
expected: Any,
) -> None:
action = make_option_map_action(return_type=return_type)
assert action._get_result_from_keys(keys) == expected
@pytest.mark.parametrize("keys", ["0", ["0", "1"]])
def test_get_result_from_keys_returns_items_mapping(keys: str | list[str]) -> None:
action = make_option_map_action(return_type=SelectionReturnType.ITEMS)
result = action._get_result_from_keys(keys)
assert isinstance(result, dict)
assert set(result) == ({keys} if isinstance(keys, str) else set(keys))
assert all(isinstance(option, SelectionOption) for option in result.values())
def test_get_result_from_keys_requires_dict_selections() -> None:
action = make_action(["alpha", "beta"])
with pytest.raises(TypeError, match="Selections must be a dictionary"):
action._get_result_from_keys("0")
def test_get_result_from_keys_rejects_unsupported_return_type() -> None:
action = make_option_map_action()
action.return_type = object() # Force defensive branch unreachable through __init__.
with pytest.raises(ValueError, match="Unsupported return type"):
action._get_result_from_keys("0")
@pytest.mark.asyncio
@pytest.mark.parametrize(
("maybe_result", "expected"),
[
("1", "1"),
("prod", "1"),
("Production", "1"),
],
)
async def test_resolve_single_default_maps_dict_key_value_and_description(
maybe_result: str,
expected: str,
) -> None:
action = make_option_map_action()
assert await action._resolve_single_default(maybe_result) == expected
@pytest.mark.asyncio
@pytest.mark.parametrize(
("maybe_result", "expected"),
[
("1", "1"),
("beta", "1"),
("missing", ""),
],
)
async def test_resolve_single_default_maps_list_index_or_value(
maybe_result: str,
expected: str,
) -> None:
action = make_action(["alpha", "beta"])
assert await action._resolve_single_default(maybe_result) == expected
@pytest.mark.asyncio
async def test_resolve_effective_default_uses_first_value_for_single_selection_defaults() -> (
None
):
action = make_action(["alpha", "beta"], default_selection=["beta"])
assert await action._resolve_effective_default() == "1"
@pytest.mark.asyncio
async def test_resolve_effective_default_uses_first_last_result_for_single_selection() -> (
None
):
action = make_action(["alpha", "beta"])
action.shared_context = FakeSharedContext(["beta"])
assert await action._resolve_effective_default() == "1"
@pytest.mark.asyncio
async def test_resolve_effective_default_joins_multi_selection_defaults() -> None:
action = make_action(
["alpha", "beta", "gamma"],
default_selection=["alpha", "gamma"],
number_selections=2,
)
assert await action._resolve_effective_default() == "0,2"
@pytest.mark.asyncio
async def test_resolve_effective_default_joins_multi_selection_last_result() -> None:
action = make_action(["alpha", "beta", "gamma"], number_selections=2)
action.shared_context = FakeSharedContext(["alpha", "gamma"])
assert await action._resolve_effective_default() == "0,2"
@pytest.mark.asyncio
async def test_resolve_effective_default_allows_unbounded_multi_selection_last_result() -> (
None
):
action = make_action(["alpha", "beta", "gamma"], number_selections="*")
action.shared_context = FakeSharedContext(["alpha", "beta", "gamma"])
assert await action._resolve_effective_default() == "0,1,2"
@pytest.mark.asyncio
async def test_resolve_effective_default_rejects_default_length_mismatch() -> None:
action = make_action(
["alpha", "beta", "gamma"],
default_selection=["alpha"],
number_selections=2,
)
with pytest.raises(ValueError, match="default_selection has a different length"):
await action._resolve_effective_default()
@pytest.mark.asyncio
async def test_resolve_effective_default_rejects_last_result_length_mismatch() -> None:
action = make_action(["alpha", "beta", "gamma"], number_selections=2)
action.shared_context = FakeSharedContext(["alpha"])
with pytest.raises(ValueError, match="last_result has a different length"):
await action._resolve_effective_default()
@pytest.mark.asyncio
async def test_resolve_effective_default_warns_when_injected_result_is_unusable(
caplog: pytest.LogCaptureFixture,
) -> None:
action = make_action(
["alpha", "beta"],
inject_last_result=True,
number_selections=2,
)
action.shared_context = FakeSharedContext("missing")
assert await action._resolve_effective_default() == ""
assert "Injected last result" in caplog.text
@pytest.mark.asyncio
async def test_run_list_headless_single_selection_uses_default() -> None:
action = make_action(["alpha", "beta"], never_prompt=True, default_selection="1")
result = await action()
assert result == "beta"
@pytest.mark.asyncio
async def test_run_list_headless_multi_selection_uses_default_list() -> None:
action = make_action(
["alpha", "beta", "gamma"],
never_prompt=True,
default_selection=["alpha", "gamma"],
number_selections=2,
)
result = await action()
assert result == ["alpha", "gamma"]
@pytest.mark.asyncio
async def test_run_dict_headless_single_selection_returns_value() -> None:
action = make_option_map_action(never_prompt=True, default_selection="1")
result = await action()
assert result == "prod"
@pytest.mark.asyncio
async def test_run_dict_headless_multi_selection_returns_configured_shape() -> None:
action = make_option_map_action(
never_prompt=True,
default_selection=["0", "2"],
number_selections=2,
return_type=SelectionReturnType.DESCRIPTION_VALUE,
)
result = await action()
assert result == {"Development": "dev", "Staging": "stage"}
@pytest.mark.asyncio
async def test_run_list_interactive_uses_prompt_for_index(
monkeypatch: pytest.MonkeyPatch,
) -> None:
action = make_action(["alpha", "beta"], never_prompt=False, show_table=False)
async def fake_prompt_for_index(*args: Any, **kwargs: Any) -> int:
assert kwargs["prompt_session"] is action.prompt_session
assert kwargs["show_table"] is False
assert kwargs["cancel_key"] == "2"
return 1
monkeypatch.setattr(
selection_action_module, "prompt_for_index", fake_prompt_for_index
)
result = await action()
assert result == "beta"
@pytest.mark.asyncio
async def test_run_dict_interactive_uses_prompt_for_selection(
monkeypatch: pytest.MonkeyPatch,
) -> None:
action = make_option_map_action(never_prompt=False, show_table=False)
async def fake_prompt_for_selection(*args: Any, **kwargs: Any) -> str:
assert kwargs["prompt_session"] is action.prompt_session
assert kwargs["show_table"] is False
assert kwargs["cancel_key"] == "3"
return "2"
monkeypatch.setattr(
selection_action_module,
"prompt_for_selection",
fake_prompt_for_selection,
)
result = await action()
assert result == "stage"
@pytest.mark.asyncio
async def test_run_raises_when_never_prompt_has_no_effective_default() -> None:
action = make_action(["alpha", "beta"], never_prompt=True)
with pytest.raises(ValueError, match="never_prompt"):
await action()
@pytest.mark.asyncio
async def test_run_list_cancel_triggers_error_and_teardown_hooks(
monkeypatch: pytest.MonkeyPatch,
) -> None:
action = make_action(["alpha", "beta"], never_prompt=True, default_selection="0")
calls = register_lifecycle_hooks(action)
async def fake_resolve_effective_default() -> str:
return "4"
monkeypatch.setattr(
action, "_resolve_effective_default", fake_resolve_effective_default
)
with pytest.raises(IndexError):
await action()
assert HookType.BEFORE in hook_types(calls)
assert HookType.ON_ERROR in hook_types(calls)
assert HookType.AFTER in hook_types(calls)
assert HookType.ON_TEARDOWN in hook_types(calls)
error_contexts = [
context for hook_type, context in calls if hook_type is HookType.ON_ERROR
]
assert isinstance(error_contexts[0].exception, IndexError)
@pytest.mark.asyncio
async def test_run_dict_cancel_triggers_cancel_signal(
monkeypatch: pytest.MonkeyPatch,
) -> None:
action = make_option_map_action(never_prompt=True, default_selection="0")
async def fake_resolve_effective_default() -> str:
return "3"
monkeypatch.setattr(
action, "_resolve_effective_default", fake_resolve_effective_default
)
with pytest.raises(CancelSignal):
await action()
@pytest.mark.asyncio
async def test_run_unsupported_selection_storage_triggers_error_lifecycle(
monkeypatch: pytest.MonkeyPatch,
) -> None:
action = make_action(["alpha"], never_prompt=False)
action._selections = SizedButUnsupportedSelections() # type: ignore[assignment]
calls = register_lifecycle_hooks(action)
async def fake_resolve_effective_default() -> str:
return ""
monkeypatch.setattr(
action, "_resolve_effective_default", fake_resolve_effective_default
)
with pytest.raises(TypeError, match="selections"):
await action()
assert HookType.ON_ERROR in hook_types(calls)
error_contexts = [
context for hook_type, context in calls if hook_type is HookType.ON_ERROR
]
assert isinstance(error_contexts[0].exception, TypeError)
@pytest.mark.asyncio
async def test_run_success_triggers_success_after_and_teardown_hooks() -> None:
action = make_action(["alpha", "beta"], never_prompt=True, default_selection="0")
calls = register_lifecycle_hooks(action)
result = await action()
assert result == "alpha"
assert hook_types(calls).count(HookType.BEFORE) == 1
assert hook_types(calls).count(HookType.ON_SUCCESS) == 1
assert hook_types(calls).count(HookType.AFTER) == 1
assert hook_types(calls).count(HookType.ON_TEARDOWN) == 1
success_contexts = [
context for hook_type, context in calls if hook_type is HookType.ON_SUCCESS
]
assert success_contexts[0].result == "alpha"
@pytest.mark.asyncio
async def test_preview_prints_tree_when_no_parent() -> None:
action = make_option_map_action(default_selection="1", never_prompt=True)
console = CaptureConsole()
action.console = console # type: ignore[assignment]
await action.preview()
assert len(console.printed) == 1
assert "SelectionAction" in str(console.printed[0][0][0].label)
@pytest.mark.asyncio
async def test_preview_adds_to_parent_when_parent_is_provided() -> None:
action = make_action(["alpha", "beta"], default_selection="0")
parent = Tree("Root")
console = CaptureConsole()
action.console = console # type: ignore[assignment]
await action.preview(parent=parent)
assert console.printed == []
assert len(parent.children) == 1
assert "SelectionAction" in str(parent.children[0].label)
def test_str_includes_action_configuration() -> None:
action = make_action(["alpha", "beta"], return_type=SelectionReturnType.KEY)
text = str(action)
assert "SelectionAction" in text
assert "ChooseThing" in text
assert "KEY" in text or "key" in text
def test_clone_copies_selection_action_configuration() -> None:
session = DummyPromptSession()
action = SelectionAction(
name="CloneMe",
selections={"A": SelectionOption("Alpha", "alpha", style="green")},
title="Letters",
columns=3,
prompt_message="Choose letter > ",
default_selection="A",
number_selections="*",
separator=";",
allow_duplicates=True,
inject_last_result=True,
inject_into="choice",
return_type=SelectionReturnType.DESCRIPTION,
prompt_session=session,
never_prompt=True,
show_table=False,
)
clone = action.clone()
assert clone is not action
assert clone.name == action.name
assert clone.title == action.title
assert clone.columns == action.columns
assert clone.prompt_message == action.prompt_message
assert clone.default_selection == action.default_selection
assert clone.number_selections == action.number_selections
assert clone.separator == action.separator
assert clone.allow_duplicates == action.allow_duplicates
assert clone.inject_last_result is True
assert clone.inject_into == "choice"
assert clone.return_type is SelectionReturnType.DESCRIPTION
assert clone.prompt_session is session
assert clone.local_never_prompt is True
assert clone.show_table is False
assert clone.selections is not action.selections
assert clone.selections["A"].description == "Alpha"

View File

@@ -0,0 +1,598 @@
from __future__ import annotations
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any
import pytest
import toml
import yaml
from rich.tree import Tree
import falyx.action.select_file_action as select_file_module
from falyx.action.action_types import FileType
from falyx.action.select_file_action import SelectFileAction
from falyx.hook_manager import HookType
from falyx.selection import SelectionOption
from falyx.signals import CancelSignal
class DummyPromptSession:
pass
class CaptureConsole:
def __init__(self) -> None:
self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
def print(self, *args: Any, **kwargs: Any) -> None:
self.printed.append((args, kwargs))
def make_action(directory: Path, **overrides: Any) -> SelectFileAction:
defaults: dict[str, Any] = {
"name": "ChooseFile",
"directory": directory,
"prompt_session": DummyPromptSession(),
}
defaults.update(overrides)
return SelectFileAction(**defaults)
def write_sample_files(directory: Path) -> dict[str, Path]:
paths = {
"text": directory / "note.txt",
"json": directory / "config.json",
"yaml": directory / "config.yaml",
"toml": directory / "config.toml",
"csv": directory / "rows.csv",
"tsv": directory / "rows.tsv",
"xml": directory / "doc.xml",
}
paths["text"].write_text("hello\n", encoding="UTF-8")
paths["json"].write_text('{"name": "falyx", "count": 2}', encoding="UTF-8")
paths["yaml"].write_text("name: falyx\nenabled: true\n", encoding="UTF-8")
paths["toml"].write_text('name = "falyx"\ncount = 2\n', encoding="UTF-8")
paths["csv"].write_text("name,count\nfalyx,2\n", encoding="UTF-8")
paths["tsv"].write_text("name\tcount\nfalyx\t2\n", encoding="UTF-8")
paths["xml"].write_text("<root><name>falyx</name></root>", encoding="UTF-8")
return paths
def register_lifecycle_hooks(action: SelectFileAction) -> list[tuple[HookType, Any]]:
calls: list[tuple[HookType, Any]] = []
def make_hook(hook_type: HookType):
def hook(context):
calls.append((hook_type, context))
return hook
for hook_type in HookType:
action.hooks.register(hook_type, make_hook(hook_type))
return calls
def hook_types(calls: list[tuple[HookType, Any]]) -> list[HookType]:
return [hook_type for hook_type, _ in calls]
def test_init_normalizes_configuration_and_string_return_type(tmp_path: Path) -> None:
session = DummyPromptSession()
action = SelectFileAction(
"ChooseConfig",
tmp_path,
title="Configs",
columns=4,
prompt_message="[bold]Pick >[/] ",
style="green",
suffix_filter=".json",
return_type="json",
encoding="utf-8",
number_selections="*",
separator=";",
allow_duplicates=True,
prompt_session=session,
never_prompt=True,
)
assert action.name == "ChooseConfig"
assert action.directory == tmp_path.resolve()
assert action.title == "Configs"
assert action.columns == 4
assert action.suffix_filter == ".json"
assert action.return_type == FileType.JSON
assert action.encoding == "utf-8"
assert action.number_selections == "*"
assert action.separator == ";"
assert action.allow_duplicates is True
assert action.prompt_session is session
assert action.local_never_prompt is True
assert "ChooseConfig" in str(action)
assert ".json" in str(action)
@pytest.mark.parametrize("number_selections", [1, 2, "*"])
def test_number_selections_accepts_positive_ints_and_star(
tmp_path: Path,
number_selections: int | str,
) -> None:
action = make_action(tmp_path, number_selections=number_selections)
assert action.number_selections == number_selections
@pytest.mark.parametrize("number_selections", [0, -1, "many", object()])
def test_number_selections_rejects_invalid_values(
tmp_path: Path,
number_selections: Any,
) -> None:
action = make_action(tmp_path)
with pytest.raises(ValueError, match="number_selections"):
action.number_selections = number_selections
def test_get_options_uses_numeric_keys_and_selection_options(tmp_path: Path) -> None:
first = tmp_path / "a.txt"
second = tmp_path / "b.txt"
first.write_text("a", encoding="UTF-8")
second.write_text("b", encoding="UTF-8")
action = make_action(tmp_path, style="cyan")
options = action.get_options([first, second])
assert list(options) == ["0", "1"]
assert options["0"] == SelectionOption(
description="a.txt",
value=first,
style="cyan",
)
assert options["1"].description == "b.txt"
assert options["1"].value == second
def test_find_cancel_key_returns_first_numeric_gap_or_next_index(tmp_path: Path) -> None:
action = make_action(tmp_path)
assert action._find_cancel_key({"0": object(), "2": object()}) == "1"
assert action._find_cancel_key({"0": object(), "1": object()}) == "2"
assert action._find_cancel_key({}) == "0"
def test_get_infer_target_disables_signature_inference(tmp_path: Path) -> None:
action = make_action(tmp_path)
assert action.get_infer_target() == (None, None)
@pytest.mark.parametrize(
("return_type", "file_key", "expected"),
[
(FileType.TEXT, "text", "hello\n"),
(FileType.PATH, "text", "PATH"),
(FileType.JSON, "json", {"name": "falyx", "count": 2}),
(FileType.YAML, "yaml", {"name": "falyx", "enabled": True}),
(FileType.TOML, "toml", {"name": "falyx", "count": 2}),
(FileType.CSV, "csv", [["name", "count"], ["falyx", "2"]]),
(FileType.TSV, "tsv", [["name", "count"], ["falyx", "2"]]),
],
)
def test_parse_file_returns_requested_representation(
tmp_path: Path,
return_type: FileType,
file_key: str,
expected: Any,
) -> None:
files = write_sample_files(tmp_path)
action = make_action(tmp_path, return_type=return_type)
result = action.parse_file(files[file_key])
if expected == "PATH":
assert result == files[file_key]
else:
assert result == expected
def test_parse_file_returns_xml_root(tmp_path: Path) -> None:
files = write_sample_files(tmp_path)
action = make_action(tmp_path, return_type=FileType.XML)
result = action.parse_file(files["xml"])
assert isinstance(result, ET.Element)
assert result.tag == "root"
assert result.findtext("name") == "falyx"
def test_clone_preserves_configuration_but_returns_distinct_action(
tmp_path: Path,
) -> None:
session = DummyPromptSession()
action = make_action(
tmp_path,
title="Pick a data file",
columns=2,
prompt_message="Select > ",
style="magenta",
suffix_filter=".json",
return_type=FileType.JSON,
encoding="utf-8",
number_selections=2,
separator="|",
allow_duplicates=True,
prompt_session=session,
never_prompt=True,
)
clone = action.clone()
assert clone is not action
assert clone.name == action.name
assert clone.directory == action.directory
assert clone.title == action.title
assert clone.columns == action.columns
assert clone.prompt_message == action.prompt_message
assert clone.style == action.style
assert clone.suffix_filter == action.suffix_filter
assert clone.return_type == action.return_type
assert clone.encoding == action.encoding
assert clone.number_selections == action.number_selections
assert clone.separator == action.separator
assert clone.allow_duplicates == action.allow_duplicates
assert clone.prompt_session is session
assert clone.local_never_prompt is True
@pytest.mark.asyncio
async def test_preview_prints_tree_when_no_parent_is_given(tmp_path: Path) -> None:
write_sample_files(tmp_path)
action = make_action(tmp_path, suffix_filter=".json", return_type=FileType.JSON)
action.console = CaptureConsole()
await action.preview()
assert len(action.console.printed) == 1
printed_tree = action.console.printed[0][0][0]
assert isinstance(printed_tree, Tree)
@pytest.mark.asyncio
async def test_preview_adds_to_existing_parent_and_limits_file_sample(
tmp_path: Path,
) -> None:
for index in range(12):
(tmp_path / f"config-{index}.json").write_text("{}", encoding="UTF-8")
(tmp_path / "ignore.txt").write_text("ignored", encoding="UTF-8")
action = make_action(tmp_path, suffix_filter=".json", return_type=FileType.JSON)
parent = Tree("root")
await action.preview(parent=parent)
assert len(parent.children) == 1
action_tree = parent.children[0]
rendered_labels = [str(child.label) for child in action_tree.children]
assert any("Suffix filter" in label and ".json" in label for label in rendered_labels)
file_list = next(
child for child in action_tree.children if str(child.label) == "[dim]Files:[/]"
)
assert len(file_list.children) == 11
assert "... (2 more)" in str(file_list.children[-1].label)
@pytest.mark.asyncio
async def test_preview_reports_directory_scan_errors(tmp_path: Path) -> None:
missing_dir = tmp_path / "missing"
action = make_action(missing_dir)
parent = Tree("root")
await action.preview(parent=parent)
action_tree = parent.children[0]
assert any(
"Error scanning directory" in str(child.label) for child in action_tree.children
)
@pytest.mark.asyncio
async def test_run_raises_for_missing_directory_and_triggers_error_lifecycle(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
action = make_action(tmp_path / "missing")
calls = register_lifecycle_hooks(action)
recorded: list[Any] = []
monkeypatch.setattr(select_file_module.er, "record", recorded.append)
with pytest.raises(FileNotFoundError, match="does not exist"):
await action("arg", flag=True)
assert hook_types(calls) == [
HookType.BEFORE,
HookType.ON_ERROR,
HookType.AFTER,
HookType.ON_TEARDOWN,
]
assert recorded
assert isinstance(recorded[0].exception, FileNotFoundError)
assert recorded[0].args == ("arg",)
assert recorded[0].kwargs == {"flag": True}
@pytest.mark.asyncio
async def test_run_raises_when_directory_path_is_file(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
directory_path = tmp_path / "not-a-dir.txt"
directory_path.write_text("not a directory", encoding="UTF-8")
action = make_action(directory_path)
calls = register_lifecycle_hooks(action)
monkeypatch.setattr(select_file_module.er, "record", lambda context: None)
with pytest.raises(NotADirectoryError, match="is not a directory"):
await action()
assert HookType.ON_ERROR in hook_types(calls)
@pytest.mark.asyncio
async def test_run_raises_when_suffix_filter_matches_no_files(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
(tmp_path / "note.txt").write_text("hello", encoding="UTF-8")
action = make_action(tmp_path, suffix_filter=".json")
calls = register_lifecycle_hooks(action)
monkeypatch.setattr(select_file_module.er, "record", lambda context: None)
with pytest.raises(FileNotFoundError, match="No files found"):
await action()
assert HookType.ON_ERROR in hook_types(calls)
@pytest.mark.asyncio
async def test_run_single_selection_returns_parsed_file_and_passes_prompt_options(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
selected = tmp_path / "note.txt"
selected.write_text("selected", encoding="UTF-8")
(tmp_path / "other.json").write_text("{}", encoding="UTF-8")
action = make_action(
tmp_path,
suffix_filter=".txt",
return_type=FileType.TEXT,
number_selections=1,
separator=";",
allow_duplicates=True,
)
calls = register_lifecycle_hooks(action)
recorded: list[Any] = []
prompt_calls: list[dict[str, Any]] = []
render_calls: list[dict[str, Any]] = []
monkeypatch.setattr(select_file_module.er, "record", recorded.append)
def fake_render_selection_dict_table(**kwargs: Any) -> object:
render_calls.append(kwargs)
return object()
async def fake_prompt_for_selection(valid_keys, table, **kwargs: Any) -> str:
prompt_calls.append({"valid_keys": list(valid_keys), "table": table, **kwargs})
return "0"
monkeypatch.setattr(
select_file_module,
"render_selection_dict_table",
fake_render_selection_dict_table,
)
monkeypatch.setattr(
select_file_module,
"prompt_for_selection",
fake_prompt_for_selection,
)
result = await action()
assert result == "selected"
assert hook_types(calls) == [
HookType.BEFORE,
HookType.ON_SUCCESS,
HookType.AFTER,
HookType.ON_TEARDOWN,
]
assert recorded[0].result == "selected"
assert render_calls[0]["title"] == action.title
assert render_calls[0]["columns"] == action.columns
assert set(render_calls[0]["selections"]) == {"0", "1"}
assert prompt_calls[0]["valid_keys"] == ["0", "1"]
assert prompt_calls[0]["prompt_session"] is action.prompt_session
assert prompt_calls[0]["prompt_message"] == action.prompt_message
assert prompt_calls[0]["number_selections"] == 1
assert prompt_calls[0]["separator"] == ";"
assert prompt_calls[0]["allow_duplicates"] is True
assert prompt_calls[0]["cancel_key"] == "1"
@pytest.mark.asyncio
async def test_run_multi_selection_returns_results_for_each_selected_file(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
first = tmp_path / "a.txt"
second = tmp_path / "b.txt"
first.write_text("a", encoding="UTF-8")
second.write_text("b", encoding="UTF-8")
action = make_action(tmp_path, return_type=FileType.PATH, number_selections=2)
monkeypatch.setattr(select_file_module.er, "record", lambda context: None)
monkeypatch.setattr(
select_file_module,
"render_selection_dict_table",
lambda **kwargs: object(),
)
async def fake_prompt_for_selection(*args: Any, **kwargs: Any) -> list[str]:
return ["0", "1"]
monkeypatch.setattr(
select_file_module,
"prompt_for_selection",
fake_prompt_for_selection,
)
result = await action()
print(result)
assert result == [first, second] or result == [second, first]
@pytest.mark.asyncio
async def test_run_cancel_selection_raises_cancel_signal_and_skips_error_hook(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
(tmp_path / "a.txt").write_text("a", encoding="UTF-8")
action = make_action(tmp_path)
calls = register_lifecycle_hooks(action)
recorded: list[Any] = []
monkeypatch.setattr(select_file_module.er, "record", recorded.append)
monkeypatch.setattr(
select_file_module,
"render_selection_dict_table",
lambda **kwargs: object(),
)
async def fake_prompt_for_selection(*args: Any, **kwargs: Any) -> str:
return kwargs["cancel_key"]
monkeypatch.setattr(
select_file_module,
"prompt_for_selection",
fake_prompt_for_selection,
)
with pytest.raises(CancelSignal, match="User canceled"):
await action()
assert hook_types(calls) == [
HookType.BEFORE,
HookType.AFTER,
HookType.ON_TEARDOWN,
]
assert recorded
assert recorded[0].exception is None
def assert_parse_file_value_error(
action: SelectFileAction,
file: Path,
*,
expected_cause_type: (
type[BaseException] | tuple[type[BaseException], ...] | None
) = None,
) -> ValueError:
with pytest.raises(ValueError) as exc_info:
action.parse_file(file)
error = exc_info.value
assert f"Failed to parse {file.name} as" in str(error)
assert error.__cause__ is not None
if expected_cause_type is not None:
assert isinstance(error.__cause__, expected_cause_type)
return error
def test_parse_file_wraps_invalid_json_errors(tmp_path: Path) -> None:
import json
broken = tmp_path / "broken.json"
broken.write_text('{"name": ', encoding="UTF-8")
action = make_action(tmp_path, return_type=FileType.JSON)
assert_parse_file_value_error(
action, broken, expected_cause_type=json.JSONDecodeError
)
def test_parse_file_wraps_invalid_toml_errors(tmp_path: Path) -> None:
broken = tmp_path / "broken.toml"
broken.write_text('name = "falyx"\ncount = ', encoding="UTF-8")
action = make_action(tmp_path, return_type=FileType.TOML)
assert_parse_file_value_error(
action, broken, expected_cause_type=toml.TomlDecodeError
)
def test_parse_file_wraps_invalid_yaml_errors(tmp_path: Path) -> None:
broken = tmp_path / "broken.yaml"
broken.write_text("name: [unterminated\n", encoding="UTF-8")
action = make_action(tmp_path, return_type=FileType.YAML)
assert_parse_file_value_error(action, broken, expected_cause_type=yaml.YAMLError)
def test_parse_file_wraps_invalid_xml_errors(tmp_path: Path) -> None:
broken = tmp_path / "broken.xml"
broken.write_text("<root><name>falyx</root>", encoding="UTF-8")
action = make_action(tmp_path, return_type=FileType.XML)
assert_parse_file_value_error(action, broken, expected_cause_type=ET.ParseError)
@pytest.mark.parametrize(
"return_type",
[
FileType.TEXT,
FileType.JSON,
FileType.YAML,
FileType.TOML,
FileType.CSV,
FileType.TSV,
FileType.XML,
],
)
def test_parse_file_wraps_missing_file_errors(
tmp_path: Path, return_type: FileType
) -> None:
missing = tmp_path / "missing.data"
action = make_action(tmp_path, return_type=return_type)
assert_parse_file_value_error(action, missing, expected_cause_type=FileNotFoundError)
@pytest.mark.parametrize("return_type", [FileType.CSV, FileType.TSV])
def test_parse_file_wraps_csv_style_open_errors(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
return_type: FileType,
) -> None:
data_file = tmp_path / "rows.data"
data_file.write_text("name,count\nfalyx,2\n", encoding="UTF-8")
action = make_action(tmp_path, return_type=return_type)
def fake_open(*args: Any, **kwargs: Any) -> Any:
raise OSError("cannot open test file")
monkeypatch.setattr("builtins.open", fake_open)
error = assert_parse_file_value_error(action, data_file, expected_cause_type=OSError)
assert "cannot open test file" in str(error)
def test_parse_file_wraps_unsupported_return_type_errors(tmp_path: Path) -> None:
data_file = tmp_path / "note.txt"
data_file.write_text("hello", encoding="UTF-8")
action = make_action(tmp_path, return_type=FileType.TEXT)
action.return_type = object() # Force the defensive unsupported-type branch.
error = assert_parse_file_value_error(
action, data_file, expected_cause_type=ValueError
)
assert "Unsupported return type" in str(error.__cause__)

View File

@@ -1,16 +1,89 @@
# test_command.py # test_command.py
import logging
from collections.abc import Callable
from types import SimpleNamespace
from typing import Any
import pytest import pytest
from pydantic import ValidationError 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.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.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.retry import RetryPolicy
from falyx.signals import CancelSignal
asyncio_default_fixture_loop_scope = "function" 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) @pytest.fixture(autouse=True)
def clean_registry(): def clean_registry():
er.clear() er.clear()
@@ -18,12 +91,10 @@ def clean_registry():
er.clear() er.clear()
# --- Dummy Action ---
async def dummy_action(): async def dummy_action():
return "ok" return "ok"
# --- Dummy IO Action ---
class DummyInputAction(BaseIOAction): class DummyInputAction(BaseIOAction):
async def _run(self, *args, **kwargs): async def _run(self, *args, **kwargs):
return "needs input" return "needs input"
@@ -32,7 +103,6 @@ class DummyInputAction(BaseIOAction):
pass pass
# --- Tests ---
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_command_creation(): async def test_command_creation():
"""Test if Command can be created with a callable.""" """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", options_manager="not_a_dict_or_callable",
) )
assert "Input should be an instance of OptionsManager" in str(exc_info.value) assert "Input should be an instance of OptionsManager" in str(exc_info.value)
@pytest.mark.asyncio
async def test_resolve_args_uses_custom_parser_and_splits_string_input() -> None:
seen: list[list[str]] = []
def custom_parser(tokens: list[str]):
seen.append(tokens)
return (("parsed",), {"tokens": tokens}, {"summary": True})
command = make_command(custom_parser=custom_parser)
args, kwargs, execution_args = await command.resolve_args("--name 'Ada Lovelace'")
assert seen == [["--name", "Ada Lovelace"]]
assert args == ("parsed",)
assert kwargs == {"tokens": ["--name", "Ada Lovelace"]}
assert execution_args == {"summary": True}
@pytest.mark.asyncio
async def test_resolve_args_rejects_non_callable_custom_parser() -> None:
command = make_command(custom_parser=lambda tokens: ((), {}, {}))
command.custom_parser = object()
with pytest.raises(NotAFalyxError, match="custom_parser must be a callable"):
await command.resolve_args([])
@pytest.mark.asyncio
async def test_resolve_args_wraps_bad_shell_input_for_custom_parser() -> None:
command = make_command(custom_parser=lambda tokens: ((), {}, {}))
with pytest.raises(CommandArgumentError, match="Failed to parse arguments"):
await command.resolve_args("'unterminated")
@pytest.mark.asyncio
async def test_resolve_args_wraps_bad_shell_input_for_command_argument_parser() -> None:
command = make_command()
with pytest.raises(CommandArgumentError, match="Failed to parse arguments"):
await command.resolve_args("'unterminated")
@pytest.mark.asyncio
async def test_resolve_args_rejects_missing_parser_when_no_custom_parser_exists() -> None:
command = make_command(custom_parser=lambda tokens: ((), {}, {}))
command.custom_parser = None
command.arg_parser = None
with pytest.raises(NotAFalyxError, match="Command has no parser configured"):
await command.resolve_args([])
@pytest.mark.asyncio
async def test_resolve_args_rejects_invalid_arg_parser_instance() -> None:
command = make_command()
command.arg_parser = object()
with pytest.raises(NotAFalyxError, match="arg_parser must be an instance"):
await command.resolve_args([])
@pytest.mark.asyncio
async def test_explicit_argument_definitions_are_added_to_default_parser() -> None:
command = make_command(
arguments=[
{
"flags": ("target",),
"help": "Deployment target",
},
{
"flags": ("--region",),
"default": "us-east",
},
]
)
args, kwargs, execution_args = await command.resolve_args(
["api", "--region", "us-west"]
)
assert args == ("api",)
assert kwargs == {"region": "us-west"}
assert execution_args == {}
@pytest.mark.asyncio
async def test_argument_config_callback_configures_existing_parser() -> None:
def configure(parser: CommandArgumentParser) -> None:
parser.add_argument("--region", default="us-east")
command = make_command(argument_config=configure)
args, kwargs, execution_args = await command.resolve_args(["--region", "us-west"])
assert args == ()
assert kwargs == {"region": "us-west"}
assert execution_args == {}
def test_base_action_inference_merges_action_metadata() -> None:
def deploy(region: str) -> None:
return None
action = FakeBaseAction(
infer_target=deploy,
metadata={"region": {"help": "Region from action metadata"}},
)
command = Command.build(
key="D",
description="Deploy command",
action=action,
auto_args=True,
)
assert command.arg_metadata["region"] == {"help": "Region from action metadata"}
assert isinstance(command.arg_parser, CommandArgumentParser)
assert "region" in command.arg_parser._positional
def test_build_validates_parser_runtime_dependencies_and_retry_policy() -> None:
with pytest.raises(NotAFalyxError, match="arg_parser"):
make_command(arg_parser=object())
with pytest.raises(NotAFalyxError, match="options_manager"):
make_command(options_manager=object())
with pytest.raises(InvalidHookError, match="HookManager"):
make_command(hooks=object())
with pytest.raises(NotAFalyxError, match="retry_policy"):
make_command(retry_policy=object())
def test_build_normalizes_execution_options_and_registers_hook_lists() -> None:
async def before(_context) -> None:
return None
async def success(_context) -> None:
return None
async def error(_context) -> None:
return None
async def after(_context) -> None:
return None
async def teardown(_context) -> None:
return None
command = make_command(
execution_options=["summary", ExecutionOption.CONFIRM],
before_hooks=[before],
success_hooks=[success],
error_hooks=[error],
after_hooks=[after],
teardown_hooks=[teardown],
spinner=True,
)
assert ExecutionOption.SUMMARY in command.execution_options
assert ExecutionOption.CONFIRM in command.execution_options
assert before in command.hooks._hooks[HookType.BEFORE]
assert success in command.hooks._hooks[HookType.ON_SUCCESS]
assert error in command.hooks._hooks[HookType.ON_ERROR]
assert after in command.hooks._hooks[HookType.AFTER]
assert teardown in command.hooks._hooks[HookType.ON_TEARDOWN]
assert command.hooks._hooks[HookType.BEFORE]
assert command.hooks._hooks[HookType.ON_TEARDOWN]
def test_model_post_init_warns_for_retry_flags_on_plain_callable(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING):
make_command(retry=True, retry_all=True)
assert "Retry requested" in caplog.text
assert "Retry all requested" in caplog.text
def test_retry_all_for_base_action_enables_policy_recursively(
monkeypatch: pytest.MonkeyPatch,
) -> None:
action = FakeBaseAction()
calls: list[tuple[BaseAction, RetryPolicy]] = []
def fake_enable_retries_recursively(
base_action: BaseAction, policy: RetryPolicy
) -> None:
calls.append((base_action, policy))
monkeypatch.setattr(
command_module,
"enable_retries_recursively",
fake_enable_retries_recursively,
)
command = Command.build(
key="D",
description="Deploy command",
action=action,
retry_all=True,
auto_args=False,
)
assert command.retry_policy.enabled is True
assert calls == [(action, command.retry_policy)]
def test_logging_hooks_are_registered_on_base_action() -> None:
action = FakeBaseAction()
Command.build(
key="D",
description="Deploy command",
action=action,
logging_hooks=True,
auto_args=False,
)
assert any(action.hooks._hooks.values())
def test_ignore_in_history_is_copied_to_base_action() -> None:
action = FakeBaseAction()
Command.build(
key="D",
description="Deploy command",
action=action,
ignore_in_history=True,
auto_args=False,
)
assert action.ignore_in_history is True
def test_retry_flag_enables_retry_on_action_instance() -> None:
action = Action("DeployAction", lambda: "ok")
Command.build(
key="D",
description="Deploy command",
action=action,
retry=True,
auto_args=False,
)
assert action.retry_policy.enabled is True
def test_confirmation_prompt_uses_custom_message() -> None:
command = make_command(confirm_message="Ship it?")
assert list(command._confirmation_prompt) == [("class:confirm", "Ship it?")]
def test_confirmation_prompt_describes_default_callable_with_static_inputs() -> None:
def deploy() -> str:
return "ok"
command = Command.build(
key="D",
description="Deploy command",
action=deploy,
args=("api",),
kwargs={"region": "us-east"},
auto_args=False,
)
plain_text = formatted_plain_text(command._confirmation_prompt)
assert "Confirm execution of" in plain_text
assert "D" in plain_text
assert "Deploy command" in plain_text
assert "calls" in plain_text
assert "args=('api',)" in plain_text
assert "kwargs={'region': 'us-east'}" in plain_text
def test_confirmation_prompt_uses_base_action_name() -> None:
command = Command.build(
key="D",
description="Deploy command",
action=FakeBaseAction("DeployAction"),
auto_args=False,
)
assert "DeployAction" in formatted_plain_text(command._confirmation_prompt)
@pytest.mark.asyncio
async def test_confirmation_cancel_previews_then_raises_cancel_signal(
monkeypatch: pytest.MonkeyPatch,
) -> None:
command = make_command(confirm=True, preview_before_confirm=True)
previewed: list[str] = []
confirmed_prompts: list[Any] = []
async def fake_preview(self: Command) -> None:
previewed.append(self.key)
async def fake_confirm(prompt) -> bool:
confirmed_prompts.append(prompt)
return False
monkeypatch.setattr(Command, "preview", fake_preview)
monkeypatch.setattr(command_module, "confirm_async", fake_confirm)
with pytest.raises(CancelSignal, match="Cancelled by confirmation"):
await command()
assert previewed == ["D"]
assert confirmed_prompts
@pytest.mark.asyncio
async def test_confirmation_accepts_and_executes_action(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[str] = []
async def fake_confirm(_prompt) -> bool:
return True
def action() -> str:
calls.append("ran")
return "done"
monkeypatch.setattr(command_module, "confirm_async", fake_confirm)
command = Command.build(
key="D",
description="Deploy command",
action=action,
confirm=True,
preview_before_confirm=False,
auto_args=False,
)
assert await command() == "done"
assert calls == ["ran"]
def test_get_option_returns_default_when_no_options_manager_is_available() -> None:
command = make_command()
command.options_manager = None
assert command.get_option("missing", "fallback") == "fallback"
def test_primary_alias_falls_back_to_command_key() -> None:
assert make_command(aliases=[]).primary_alias == "D"
def test_usage_reports_no_arguments_when_parser_is_absent() -> None:
command = make_command(custom_parser=lambda tokens: ((), {}, {}))
assert command.usage == "No arguments defined."
def test_usage_delegates_to_arg_parser_when_available() -> None:
command = make_command(aliases=["deploy"])
assert "D" in command.usage
assert "deploy" in command.usage
def test_help_signature_full_mode_includes_help_text_and_tags() -> None:
command = make_command(help_text="Detailed deploy help", tags=["deploy", "cloud"])
usage, description, tags = command.help_signature
assert "D" in usage
assert "Detailed deploy help" in description
assert "deploy, cloud" in tags
def test_help_signature_simple_mode_uses_key_and_aliases() -> None:
command = make_command(
aliases=["deploy"],
help_text="Detailed deploy help",
simple_help_signature=True,
)
usage, description, tags = command.help_signature
assert "D" in usage
assert "deploy" in usage
assert "Detailed deploy help" in description
assert tags == ""
def test_log_summary_delegates_to_existing_context() -> None:
command = make_command()
calls: list[str] = []
command._context = SimpleNamespace(log_summary=lambda: calls.append("logged"))
command.log_summary()
assert calls == ["logged"]
def test_render_usage_prefers_custom_usage(monkeypatch: pytest.MonkeyPatch) -> None:
captured = CaptureConsole()
monkeypatch.setattr(command_module, "console", captured)
command = make_command(custom_usage=lambda: "custom usage")
command.render_usage()
assert captured.printed[0][0] == ("custom usage",)
def test_render_usage_falls_back_to_command_key_without_parser(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured = CaptureConsole()
monkeypatch.setattr(command_module, "console", captured)
command = make_command(custom_parser=lambda tokens: ((), {}, {}))
command.render_usage()
assert captured.printed[0][0] == ("[bold]usage:[/] D",)
def test_render_help_and_tldr_custom_renderers_return_true(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured = CaptureConsole()
monkeypatch.setattr(command_module, "console", captured)
command = make_command(
custom_help=lambda: "custom help",
custom_tldr=lambda: "custom tldr",
)
assert command.render_help() is True
assert command.render_tldr() is True
assert [printed[0][0] for printed in captured.printed] == [
"custom help",
"custom tldr",
]
def test_render_help_and_tldr_return_false_without_parser_or_custom_renderer() -> None:
command = make_command(custom_parser=lambda tokens: ((), {}, {}))
assert command.render_help() is False
assert command.render_tldr() is False
@pytest.mark.asyncio
async def test_preview_renders_plain_callable_details(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured = CaptureConsole()
monkeypatch.setattr(command_module, "console", captured)
def deploy() -> str:
return "ok"
command = Command.build(
key="D",
description="Deploy command",
action=deploy,
args=("api",),
kwargs={"region": "us-east"},
help_text="Preview help",
auto_args=False,
)
await command.preview()
rendered = "\n".join(str(args[0]) for args, _ in captured.printed)
assert "Command:" in rendered
assert "Preview help" in rendered
assert "Would call:" in rendered
assert "args=('api',), kwargs={'region': 'us-east'}" in rendered
@pytest.mark.asyncio
async def test_preview_renders_base_action_tree(monkeypatch: pytest.MonkeyPatch) -> None:
captured = CaptureConsole()
monkeypatch.setattr(command_module, "console", captured)
action = FakeBaseAction("DeployAction")
command = Command.build(
key="D",
description="Deploy command",
action=action,
help_text="Preview help",
auto_args=False,
)
await command.preview()
assert action.preview_calls == 1
assert captured.printed
@pytest.mark.asyncio
async def test_call_merges_static_and_invocation_inputs_and_triggers_hooks() -> None:
events: list[tuple[str, Any]] = []
async def before(context) -> None:
events.append(("before", context.args))
async def success(context) -> None:
events.append(("success", context.result))
async def after(context) -> None:
events.append(("after", context.result))
async def teardown(context) -> None:
events.append(("teardown", context.result))
def action(*args: Any, **kwargs: Any) -> dict[str, Any]:
return {"args": args, "kwargs": kwargs}
command = Command.build(
key="D",
description="Deploy command",
action=action,
args=("static",),
kwargs={"region": "us-east"},
before_hooks=[before],
success_hooks=[success],
after_hooks=[after],
teardown_hooks=[teardown],
auto_args=False,
)
result = await command("runtime", region="us-west")
assert result == {
"args": ("runtime", "static"),
"kwargs": {"region": "us-west"},
}
assert command.result == result
assert events == [
("before", ("runtime", "static")),
("success", result),
("after", result),
("teardown", result),
]
@pytest.mark.asyncio
async def test_call_triggers_error_after_and_teardown_hooks_on_failure() -> None:
events: list[tuple[str, str | None]] = []
async def on_error(context) -> None:
events.append(("error", str(context.exception)))
async def after(context) -> None:
events.append(("after", str(context.exception)))
async def teardown(context) -> None:
events.append(("teardown", str(context.exception)))
def action() -> None:
raise RuntimeError("boom")
command = Command.build(
key="D",
description="Deploy command",
action=action,
error_hooks=[on_error],
after_hooks=[after],
teardown_hooks=[teardown],
auto_args=False,
)
with pytest.raises(RuntimeError, match="boom"):
await command()
assert events == [
("error", "boom"),
("after", "boom"),
("teardown", "boom"),
]
def test_str_includes_command_identity() -> None:
text = str(make_command())
assert "Command(key='D'" in text
assert "Deploy command" in text
def test_clone_with_overrides_clones_parser_hooks_and_base_action() -> None:
action = FakeBaseAction("DeployAction")
async def before(_context) -> None:
return None
command = Command.build(
key="D",
description="Deploy command",
action=action,
aliases=["deploy"],
before_hooks=[before],
auto_args=False,
)
clone = command.clone_with_overrides(
key="P",
description="Promote command",
aliases=["promote"],
)
assert clone.key == "P"
assert clone.description == "Promote command"
assert clone.aliases == ["promote"]
assert clone.action is not command.action
assert isinstance(clone.action, FakeBaseAction)
assert clone.hooks is not command.hooks
assert before in clone.hooks._hooks[HookType.BEFORE]
assert isinstance(clone.arg_parser, CommandArgumentParser)
assert clone.arg_parser.command_key == "P"
def test_clone_with_overrides_can_replace_action_and_execution_options() -> None:
command = make_command(execution_options=["summary"])
def replacement() -> str:
return "replacement"
clone = command.clone_with_overrides(
action=replacement,
execution_options=[ExecutionOption.CONFIRM],
simple_help_signature=True,
)
assert clone.action is not command.action
assert ExecutionOption.CONFIRM in clone.execution_options
assert ExecutionOption.SUMMARY not in clone.execution_options
assert clone.simple_help_signature is True

341
tests/test_context.py Normal file
View File

@@ -0,0 +1,341 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import pytest
from rich.console import Console
from falyx.context import ExecutionContext, InvocationContext, SharedContext
from falyx.mode import FalyxMode
class DummyAction:
def __init__(self, name: str = "DummyAction") -> None:
self.name = name
def __str__(self) -> str:
return self.name
def make_execution_context(**overrides: Any) -> ExecutionContext:
defaults: dict[str, Any] = {
"name": "Build",
"action": DummyAction("build"),
}
defaults.update(overrides)
return ExecutionContext(**defaults)
def make_shared_context(**overrides: Any) -> SharedContext:
defaults: dict[str, Any] = {
"name": "Workflow",
"action": DummyAction("workflow"),
}
defaults.update(overrides)
return SharedContext(**defaults)
def test_execution_context_get_shared_context_returns_existing_context() -> None:
shared = make_shared_context()
context = make_execution_context(shared_context=shared)
assert context.get_shared_context() is shared
def test_execution_context_get_shared_context_raises_when_missing() -> None:
context = make_execution_context()
with pytest.raises(ValueError, match="SharedContext is not set"):
context.get_shared_context()
def test_execution_context_duration_handles_not_started_running_and_stopped(
monkeypatch: pytest.MonkeyPatch,
) -> None:
context = make_execution_context()
assert context.duration is None
context.start_time = 10.0
context.end_time = None
monkeypatch.setattr("falyx.context.time.perf_counter", lambda: 12.5)
assert context.duration == pytest.approx(2.5)
context.end_time = 14.0
assert context.duration == pytest.approx(4.0)
def test_execution_context_start_and_stop_timer_populate_timer_fields() -> None:
context = make_execution_context()
context.start_timer()
assert context.start_wall is not None
assert context.start_time is not None
context.stop_timer()
assert context.end_wall is not None
assert context.end_time is not None
assert context.duration is not None
assert context.duration >= 0
def test_execution_context_exception_setter_records_traceback_and_status() -> None:
context = make_execution_context(result="ignored after failure")
context.exception = RuntimeError("boom")
assert isinstance(context.exception, RuntimeError)
assert context.success is False
assert context.status == "ERROR"
assert context.traceback is not None
assert "RuntimeError: boom" in context.traceback
def test_execution_context_as_dict_includes_result_exception_traceback_duration_and_extra() -> (
None
):
context = make_execution_context(
result={"artifact": "dist/app.whl"},
start_time=2.0,
end_time=5.25,
extra={"attempt": 2},
)
context.exception = ValueError("invalid build")
summary = context.as_dict()
assert summary["name"] == "Build"
assert summary["result"] == {"artifact": "dist/app.whl"}
assert summary["exception"] == "ValueError('invalid build')"
assert "ValueError: invalid build" in summary["traceback"]
assert summary["duration"] == pytest.approx(3.25)
assert summary["extra"] == {"attempt": 2}
def test_execution_context_signature_formats_args_and_kwargs() -> None:
context = make_execution_context(args=("src", 3), kwargs={"verbose": True})
assert context.signature == "build ('src', 3, verbose=True)"
def test_execution_context_log_summary_prints_success_to_context_console() -> None:
recording_console = Console(record=True, width=160)
context = make_execution_context(
result="ok",
start_time=1.0,
end_time=2.5,
start_wall=datetime(2026, 6, 7, 11, 0, 0),
end_wall=datetime(2026, 6, 7, 11, 0, 2),
console=recording_console,
)
context.log_summary()
output = recording_console.export_text()
assert "[SUMMARY] Build" in output
assert "Start: 11:00:00" in output
assert "End: 11:00:02" in output
assert "Duration: 1.500s" in output
assert "Result: ok" in output
def test_execution_context_log_summary_uses_logger_and_includes_exception() -> None:
messages: list[str] = []
context = make_execution_context(
result="unused",
start_time=10.0,
end_time=11.0,
)
context.exception = OSError("disk full")
context.log_summary(logger=messages.append)
assert len(messages) == 1
assert "[SUMMARY] Build" in messages[0]
assert "Duration: 1.000s" in messages[0]
assert "Exception: OSError('disk full')" in messages[0]
def test_execution_context_to_log_line_renders_success_and_error_states() -> None:
success = make_execution_context(result="ok", start_time=1.0, end_time=1.5)
failure = make_execution_context(result=None, start_time=2.0, end_time=3.0)
failure.exception = LookupError("missing")
assert success.to_log_line() == (
"[Build] status=OK duration=0.500s result='ok' exception=None"
)
assert failure.to_log_line() == (
"[Build] status=ERROR duration=1.000s result=None "
"exception=LookupError: missing"
)
def test_execution_context_str_and_repr_render_success_with_no_duration() -> None:
context = make_execution_context(result=["ok"])
text = str(context)
debug = repr(context)
assert "<ExecutionContext 'Build' | OK | Duration: n/a" in text
assert "Result: ['ok']" in text
assert "ExecutionContext(name='Build', duration=n/a" in debug
assert "result=['ok']" in debug
def test_execution_context_str_and_repr_render_exception_with_duration() -> None:
context = make_execution_context(start_time=1.0, end_time=1.75)
context.exception = RuntimeError("failed")
text = str(context)
debug = repr(context)
assert "<ExecutionContext 'Build' | ERROR | Duration: 0.750s" in text
assert "Exception: failed" in text
assert "duration=0.750" in debug
assert "exception=RuntimeError('failed')" in debug
def test_shared_context_records_results_errors_and_share_values() -> None:
shared = make_shared_context()
error = RuntimeError("step failed")
shared.add_result("first")
shared.add_error(1, error)
shared.set("artifact", "dist/app.whl")
assert shared.results == ["first"]
assert shared.errors == [(1, error)]
assert shared.get("artifact") == "dist/app.whl"
assert shared.get("missing", "default") == "default"
assert shared.last_result() == "first"
def test_shared_context_last_result_returns_none_when_sequential_context_has_no_results() -> (
None
):
shared = make_shared_context()
assert shared.last_result() is None
def test_shared_context_set_shared_result_does_not_append_for_sequential_context() -> (
None
):
shared = make_shared_context(is_concurrent=False)
shared.set_shared_result("shared-value")
assert shared.shared_result == "shared-value"
assert shared.results == []
assert shared.last_result() is None
def test_shared_context_set_shared_result_appends_and_reads_from_concurrent_context() -> (
None
):
shared = make_shared_context(is_concurrent=True)
shared.set_shared_result("group-value")
assert shared.shared_result == "group-value"
assert shared.results == ["group-value"]
assert shared.last_result() == "group-value"
def test_shared_context_str_marks_sequential_and_concurrent_modes() -> None:
sequential = make_shared_context(results=["a"])
concurrent = make_shared_context(is_concurrent=True, results=["b"])
assert "<SequentialSharedContext 'Workflow'" in str(sequential)
assert "Results: ['a']" in str(sequential)
assert "<ConcurrentSharedContext 'Workflow'" in str(concurrent)
assert "Results: ['b']" in str(concurrent)
def test_invocation_context_menu_path_segment_operations_are_immutable() -> None:
root = InvocationContext(program="falyx", mode=FalyxMode.MENU)
one = root.with_path_segment("admin", style="cyan")
two = one.with_path_segment("deploy", style="green")
trimmed = two.without_last_path_segment()
assert root.typed_path == []
assert root.segments == []
assert one.typed_path == ["admin"]
assert one.segments[0].text == "admin"
assert str(one.segments[0].style) == "cyan"
assert two.typed_path == ["admin", "deploy"]
assert trimmed.typed_path == ["admin"]
assert trimmed.segments[0].text == "admin"
assert root.without_last_path_segment() is root
def test_invocation_context_plain_path_omits_program_in_menu_mode() -> None:
context = (
InvocationContext(program="falyx", mode=FalyxMode.MENU)
.with_path_segment("admin")
.with_path_segment("deploy")
)
assert context.is_cli_mode is False
assert context.plain_path == "admin deploy"
def test_invocation_context_plain_path_includes_program_in_cli_mode() -> None:
context = (
InvocationContext(program="falyx", mode=FalyxMode.COMMAND)
.with_path_segment("admin")
.with_path_segment("deploy")
)
assert context.is_cli_mode is True
assert context.plain_path == "falyx admin deploy"
def test_invocation_context_plain_path_handles_cli_context_without_program() -> None:
context = InvocationContext(mode=FalyxMode.COMMAND).with_path_segment("deploy")
assert context.plain_path == "deploy"
def test_invocation_context_markup_path_styles_program_and_segments_and_escapes_text() -> (
None
):
context = (
InvocationContext(
program="falyx[dev]",
program_style="bold blue",
mode=FalyxMode.COMMAND,
)
.with_path_segment("admin[ops]", style="cyan")
.with_path_segment("deploy", style="green")
)
assert context.markup_path == (
"[bold blue]falyx\\[dev][/bold blue] "
"[cyan]admin\\[ops][/cyan] "
"[green]deploy[/green]"
)
def test_invocation_context_markup_path_handles_unstyled_program_and_segments() -> None:
context = (
InvocationContext(program="falyx", mode=FalyxMode.COMMAND)
.with_path_segment("admin[ops]")
.with_path_segment("deploy")
)
assert context.markup_path == "falyx admin\\[ops] deploy"
def test_invocation_context_markup_path_omits_program_in_menu_mode() -> None:
context = (
InvocationContext(
program="falyx",
program_style="bold blue",
mode=FalyxMode.MENU,
)
.with_path_segment("admin", style="cyan")
.with_path_segment("deploy")
)
assert context.markup_path == "[cyan]admin[/cyan] deploy"

View File

@@ -0,0 +1,307 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Iterator
import pytest
from rich.console import Console
from rich.table import Table
from falyx.execution_registry import ExecutionRegistry
@dataclass
class DummyAction:
ignore_in_history: bool = False
class DummyContext:
def __init__(
self,
name: str,
*,
result: Any = None,
exception: Exception | None = None,
traceback: str = "",
signature: str | None = None,
start_time: float | None = 1_700_000_000.0,
end_time: float | None = 1_700_000_001.0,
duration: float | None = 1.25,
ignore_in_history: bool = False,
) -> None:
self.index = -1
self.name = name
self.result = result
self.exception = exception
self.traceback = traceback
self.signature = signature or f"{name}()"
self.start_time = start_time
self.end_time = end_time
self.duration = duration
self.action = DummyAction(ignore_in_history=ignore_in_history)
self.success = exception is None
def to_log_line(self) -> str:
return f"log:{self.name}:{self.index}"
class CaptureConsole:
def __init__(self) -> None:
self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
def print(self, *args: Any, **kwargs: Any) -> None:
self.printed.append((args, kwargs))
def rendered_text(self) -> str:
output = Console(record=True, width=160)
for args, kwargs in self.printed:
output.print(*args, **kwargs)
return output.export_text()
@pytest.fixture(autouse=True)
def isolated_registry() -> Iterator[CaptureConsole]:
original_console = ExecutionRegistry._console
capture = CaptureConsole()
ExecutionRegistry._console = capture # type: ignore[assignment]
ExecutionRegistry._store_by_name.clear()
ExecutionRegistry._store_by_index.clear()
ExecutionRegistry._store_all.clear()
ExecutionRegistry._index = 0
yield capture
ExecutionRegistry._store_by_name.clear()
ExecutionRegistry._store_by_index.clear()
ExecutionRegistry._store_all.clear()
ExecutionRegistry._index = 0
ExecutionRegistry._console = original_console
def record_context(*args: Any, **kwargs: Any) -> DummyContext:
context = DummyContext(*args, **kwargs)
ExecutionRegistry.record(context) # type: ignore[arg-type]
return context
def latest_printed_table(console: CaptureConsole) -> Table:
assert console.printed
table = console.printed[-1][0][0]
assert isinstance(table, Table)
return table
def test_record_assigns_indexes_and_populates_all_lookup_stores() -> None:
first = record_context("Build", result="ok")
second = record_context("Build", result="again")
other = record_context("Deploy", result="done")
assert first.index == 0
assert second.index == 1
assert other.index == 2
assert ExecutionRegistry.get_all() == [first, second, other]
assert ExecutionRegistry.get_by_name("Build") == [first, second]
assert ExecutionRegistry.get_by_name("missing") == []
assert ExecutionRegistry._store_by_index == {0: first, 1: second, 2: other}
assert ExecutionRegistry.get_latest() is other
def test_clear_removes_all_recorded_contexts() -> None:
record_context("Build", result="ok")
ExecutionRegistry.clear()
assert ExecutionRegistry.get_all() == []
assert ExecutionRegistry.get_by_name("Build") == []
assert ExecutionRegistry._store_by_index == {}
def test_summary_clear_clears_registry_and_prints_confirmation(
isolated_registry: CaptureConsole,
) -> None:
record_context("Build", result="ok")
ExecutionRegistry.summary(clear=True)
assert ExecutionRegistry.get_all() == []
assert "Execution history cleared" in isolated_registry.rendered_text()
def test_summary_last_result_skips_ignored_contexts(
isolated_registry: CaptureConsole,
) -> None:
visible = record_context("Visible", result={"answer": 42})
record_context("Ignored", result="do not show", ignore_in_history=True)
ExecutionRegistry.summary(last_result=True)
assert isolated_registry.printed[0][0] == (f"{visible.signature}:",)
assert isolated_registry.printed[1][0] == (visible.result,)
def test_summary_last_result_prints_traceback_when_latest_visible_context_failed(
isolated_registry: CaptureConsole,
) -> None:
failed = record_context("Fail", exception=RuntimeError("boom"), traceback="TRACEBACK")
ExecutionRegistry.summary(last_result=True)
assert isolated_registry.printed[0][0] == (f"{failed.signature}:",)
assert isolated_registry.printed[1][0] == ("TRACEBACK",)
def test_summary_last_result_reports_when_all_contexts_are_ignored(
isolated_registry: CaptureConsole,
) -> None:
record_context("Ignored", result="hidden", ignore_in_history=True)
ExecutionRegistry.summary(last_result=True)
assert "No valid executions found" in isolated_registry.rendered_text()
def test_summary_result_index_prints_result_for_existing_context(
isolated_registry: CaptureConsole,
) -> None:
context = record_context("Build", result=["artifact.whl"])
ExecutionRegistry.summary(result_index=context.index)
assert isolated_registry.printed[0][0] == (f"{context.signature}:",)
assert isolated_registry.printed[1][0] == (context.result,)
def test_summary_result_index_prints_traceback_for_failed_context(
isolated_registry: CaptureConsole,
) -> None:
context = record_context("Fail", exception=ValueError("bad"), traceback="STACK")
ExecutionRegistry.summary(result_index=context.index)
assert isolated_registry.printed[0][0] == (f"{context.signature}:",)
assert isolated_registry.printed[1][0] == ("STACK",)
def test_summary_result_index_reports_missing_index(
isolated_registry: CaptureConsole,
) -> None:
ExecutionRegistry.summary(result_index=99)
assert "No execution found for index 99" in isolated_registry.rendered_text()
def test_summary_name_filter_reports_missing_action(
isolated_registry: CaptureConsole,
) -> None:
record_context("Build", result="ok")
ExecutionRegistry.summary(name="Deploy")
assert "No executions found for action 'Deploy'" in isolated_registry.rendered_text()
def test_summary_name_filter_renders_only_matching_contexts(
isolated_registry: CaptureConsole,
) -> None:
record_context("Build", result="ok")
record_context("Deploy", result="done")
record_context("Build", result="again")
ExecutionRegistry.summary(name="Build")
table = latest_printed_table(isolated_registry)
assert table.title == "📊 Execution History for 'Build'"
assert len(table.rows) == 2
rendered = isolated_registry.rendered_text()
assert "Build" in rendered
assert "Deploy" not in rendered
def test_summary_index_filter_renders_existing_context(
isolated_registry: CaptureConsole,
capsys: pytest.CaptureFixture[str],
) -> None:
first = record_context("Build", result="ok")
second = record_context("Deploy", result="done")
ExecutionRegistry.summary(index=second.index)
table = latest_printed_table(isolated_registry)
assert table.title == f"📊 Execution History for Index {second.index}"
assert len(table.rows) == 1
rendered = isolated_registry.rendered_text()
assert "Deploy" in rendered
assert "Build" not in rendered
# The implementation currently prints the filtered context list directly.
assert str([second]) in capsys.readouterr().out
assert first.index == 0
def test_summary_index_filter_reports_missing_index(
isolated_registry: CaptureConsole,
) -> None:
ExecutionRegistry.summary(index=12)
assert "No execution found for index 12" in isolated_registry.rendered_text()
def test_summary_status_success_filters_out_errors_and_truncates_long_results(
isolated_registry: CaptureConsole,
) -> None:
long_result = "x" * 80
record_context("Success", result=long_result)
record_context("Failure", exception=RuntimeError("boom"))
ExecutionRegistry.summary(status="success")
table = latest_printed_table(isolated_registry)
assert len(table.rows) == 1
rendered = isolated_registry.rendered_text()
assert "Success" in rendered
assert "Failure" not in rendered
assert "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..." in rendered
def test_summary_status_error_filters_out_successes(
isolated_registry: CaptureConsole,
) -> None:
record_context("Success", result="ok")
record_context("Failure", exception=RuntimeError("boom"))
ExecutionRegistry.summary(status="error")
table = latest_printed_table(isolated_registry)
assert len(table.rows) == 1
rendered = isolated_registry.rendered_text()
assert "Failure" in rendered
assert "RuntimeError" in rendered
assert "Success" not in rendered
def test_summary_uses_na_for_missing_timestamps_and_duration(
isolated_registry: CaptureConsole,
) -> None:
record_context("Pending", result=None, start_time=None, end_time=None, duration=None)
ExecutionRegistry.summary()
rendered = isolated_registry.rendered_text()
assert "Pending" in rendered
assert "n/a" in rendered
def test_summary_defaults_to_all_contexts(
isolated_registry: CaptureConsole,
) -> None:
record_context("One", result="ok")
record_context("Two", exception=RuntimeError("boom"))
ExecutionRegistry.summary()
table = latest_printed_table(isolated_registry)
assert table.title == "📊 Execution History"
assert len(table.rows) == 2
rendered = isolated_registry.rendered_text()
assert "One" in rendered
assert "Two" in rendered

View File

@@ -0,0 +1,55 @@
import logging
from falyx import Falyx
from falyx.action import Action
from falyx.debug import log_after, log_before, log_error, log_success
from falyx.hook_manager import HookType
def test_apply_root_options_sets_falyx_logger_level_from_root_verbose():
flx = Falyx()
falyx_logger = logging.getLogger("falyx")
original_level = falyx_logger.level
try:
flx.options_manager.set("verbose", True, "root")
flx._apply_root_options()
assert falyx_logger.level == logging.DEBUG
flx.options_manager.set("verbose", False, "root")
flx._apply_root_options()
assert falyx_logger.level == logging.WARNING
finally:
falyx_logger.setLevel(original_level)
def test_apply_root_options_registers_debug_hooks_across_command_and_action_graph():
action = Action("deploy-action", lambda: "ok")
flx = Falyx()
command = flx.add_command(
key="D",
description="Deploy",
action=action,
)
assert flx.hooks._hooks[HookType.BEFORE] == []
assert command.hooks._hooks[HookType.BEFORE] == []
assert action.hooks._hooks[HookType.BEFORE] == []
flx.options_manager.set("debug_hooks", True, "root")
flx._apply_root_options()
assert flx.hooks._hooks[HookType.BEFORE] == [log_before]
assert flx.hooks._hooks[HookType.ON_SUCCESS] == [log_success]
assert flx.hooks._hooks[HookType.ON_ERROR] == [log_error]
assert flx.hooks._hooks[HookType.AFTER] == [log_after]
assert command.hooks._hooks[HookType.BEFORE] == [log_before]
assert command.hooks._hooks[HookType.ON_SUCCESS] == [log_success]
assert command.hooks._hooks[HookType.ON_ERROR] == [log_error]
assert command.hooks._hooks[HookType.AFTER] == [log_after]
assert action.hooks._hooks[HookType.BEFORE] == [log_before]
assert action.hooks._hooks[HookType.ON_SUCCESS] == [log_success]
assert action.hooks._hooks[HookType.ON_ERROR] == [log_error]
assert action.hooks._hooks[HookType.AFTER] == [log_after]

View File

@@ -0,0 +1,138 @@
import pytest
from falyx import Falyx
from falyx.action import Action, ChainedAction
from falyx.command import Command
from falyx.options_manager import OptionsManager
from falyx.parser import CommandArgumentParser
def test_add_command_from_command_returns_bound_clone():
source = Falyx(program="source")
target = Falyx(program="target")
original = source.add_command(
"D",
"Deploy",
action=lambda: "ok",
aliases=["deploy"],
help_text="Deploy something.",
)
bound = target.add_command_from_command(original)
assert bound is target.commands["D"]
assert bound is not original
assert bound.key == original.key
assert bound.description == original.description
assert bound.aliases == original.aliases
assert bound.program == target.program
def test_add_command_from_command_does_not_reuse_original_options_manager():
source = Falyx(program="source")
target = Falyx(program="target")
original = source.add_command("D", "Deploy", action=lambda: "ok")
bound = target.add_command_from_command(original)
assert original.options_manager is source.options_manager
assert bound.options_manager is target.options_manager
assert bound.options_manager is not original.options_manager
def test_add_command_from_command_returns_isolated_clone():
flx1 = Falyx(program="one")
flx2 = Falyx(program="two")
original = flx1.add_command("D", "Deploy", action=Action("deploy", lambda: "ok"))
bound = flx2.add_command_from_command(original)
assert bound is not original
assert bound.options_manager is flx2.options_manager
assert original.options_manager is flx1.options_manager
if bound.arg_parser and original.arg_parser:
assert bound.arg_parser is not original.arg_parser
assert bound.arg_parser.options_manager is flx2.options_manager
assert original.arg_parser.options_manager is flx1.options_manager
assert bound.action is not original.action
def test_clone_with_overrides_clones_arg_parser_and_base_action_graph():
original_options = OptionsManager()
cloned_options = OptionsManager()
parser = CommandArgumentParser(
command_key="D",
command_description="Deploy",
options_manager=original_options,
)
parser.add_argument("--region", default="us-east")
action = ChainedAction(
name="deploy-flow",
actions=[
Action("step-one", lambda: "one"),
Action("step-two", lambda: "two"),
],
)
command = Command.build(
key="D",
description="Deploy",
action=action,
arg_parser=parser,
options_manager=original_options,
program="source",
)
cloned = command.clone_with_overrides(
options_manager=cloned_options,
program="target",
)
assert cloned is not command
assert cloned.program == "target"
assert cloned.options_manager is cloned_options
assert command.options_manager is original_options
assert cloned.arg_parser is not command.arg_parser
assert cloned.arg_parser.options_manager is cloned_options
assert command.arg_parser.options_manager is original_options
assert cloned.action is not command.action
assert isinstance(cloned.action, ChainedAction)
assert isinstance(command.action, ChainedAction)
assert cloned.action.actions is not command.action.actions
assert len(cloned.action.actions) == len(command.action.actions)
for cloned_child, original_child in zip(
cloned.action.actions,
command.action.actions,
strict=True,
):
assert cloned_child is not original_child
assert cloned_child.name == original_child.name
cloned.arg_parser.add_argument("--profile", default="dev")
assert command.arg_parser.get_argument("profile") is None
def test_clone_with_overrides_preserves_boolean_contract_flags():
command = Command.build(
"H",
"Hidden-ish helper",
lambda: None,
auto_args=False,
simple_help_signature=True,
ignore_in_history=True,
)
cloned = command.clone_with_overrides()
assert cloned.auto_args is False
assert cloned.simple_help_signature is True
assert cloned.ignore_in_history is True

View File

@@ -0,0 +1,197 @@
from unittest.mock import AsyncMock
import pytest
from falyx.action import Action
from falyx.command import Command
from falyx.options_manager import OptionsManager
from falyx.prompt_utils import should_prompt_user
from falyx.signals import CancelSignal
def _make_options() -> OptionsManager:
options = OptionsManager()
options.from_mapping({}, "root")
options.from_mapping({}, "execution")
return options
@pytest.mark.asyncio
async def test_command_handle_prompt_respects_action_local_never_prompt(monkeypatch):
options = _make_options()
command = Command.build(
key="D",
description="Deploy",
action=Action("deploy-action", lambda: "ok", never_prompt=True),
confirm=True,
preview_before_confirm=True,
options_manager=options,
)
calls = {
"preview": 0,
"confirm": 0,
"should_prompt": 0,
"action_never_prompt": None,
}
async def fake_preview(self):
calls["preview"] += 1
async def fake_confirm(*args, **kwargs):
calls["confirm"] += 1
return True
def fake_should_prompt_user(*, confirm, options, action_never_prompt=None, **kwargs):
calls["should_prompt"] += 1
calls["action_never_prompt"] = action_never_prompt
return False
monkeypatch.setattr(Command, "preview", fake_preview)
monkeypatch.setattr("falyx.command.confirm_async", fake_confirm)
monkeypatch.setattr("falyx.command.should_prompt_user", fake_should_prompt_user)
await command._handle_prompt_user()
assert calls["should_prompt"] == 1
assert calls["action_never_prompt"] is True
assert calls["preview"] == 0
assert calls["confirm"] == 0
def test_should_prompt_user_precedence_execution_over_root():
options = _make_options()
options.set("force_confirm", True, "root")
options.set("skip_confirm", True, "execution")
assert should_prompt_user(confirm=False, options=options) is False
options = _make_options()
options.set("never_prompt", False, "root")
options.set("force_confirm", True, "execution")
assert should_prompt_user(confirm=False, options=options) is True
@pytest.mark.asyncio
async def test_command_local_never_prompt_overrides_root_prompt_behavior(monkeypatch):
options = _make_options()
options.set("force_confirm", True, "root")
command = Command.build(
key="D",
description="Deploy",
action=Action("deploy-action", lambda: "ok", never_prompt=True),
confirm=False,
preview_before_confirm=True,
options_manager=options,
)
calls = {
"preview": 0,
"confirm": 0,
}
async def fake_preview(self):
calls["preview"] += 1
async def fake_confirm(*args, **kwargs):
calls["confirm"] += 1
return True
monkeypatch.setattr(Command, "preview", fake_preview)
monkeypatch.setattr("falyx.command.confirm_async", fake_confirm)
await command._handle_prompt_user()
assert calls["preview"] == 0
assert calls["confirm"] == 0
@pytest.mark.asyncio
async def test_command_call_invokes_handle_prompt_user(monkeypatch):
options = OptionsManager()
options.from_mapping({}, "root")
options.from_mapping({}, "execution")
command = Command.build(
key="D",
description="Deploy",
action=Action("deploy-action", lambda: "ok"),
confirm=True,
options_manager=options,
)
mocked_handle_prompt = AsyncMock()
monkeypatch.setattr(command, "_handle_prompt_user", mocked_handle_prompt)
result = await command()
mocked_handle_prompt.assert_awaited_once()
assert result == "ok"
@pytest.mark.asyncio
async def test_command_call_invokes_handle_prompt_user_before_action(monkeypatch) -> None:
trace: list[str] = []
async def run_action():
trace.append("action")
return "ok"
options = OptionsManager()
options.from_mapping({}, "root")
options.from_mapping({}, "execution")
command = Command.build(
key="D",
description="Deploy",
action=Action("deploy-action", run_action),
confirm=True,
options_manager=options,
)
async def fake_handle_prompt_user():
trace.append("prompt")
monkeypatch.setattr(command, "_handle_prompt_user", fake_handle_prompt_user)
result = await command()
assert result == "ok"
assert trace == ["prompt", "action"]
@pytest.mark.asyncio
async def test_command_call_cancels_before_action_when_handle_prompt_user_raises(
monkeypatch,
):
trace: list[str] = []
async def run_action():
trace.append("action")
return "ok"
options = OptionsManager()
options.from_mapping({}, "root")
options.from_mapping({}, "execution")
command = Command.build(
key="D",
description="Deploy",
action=Action("deploy-action", run_action),
confirm=True,
options_manager=options,
)
async def fake_handle_prompt_user():
trace.append("prompt")
raise CancelSignal("cancelled during confirmation")
monkeypatch.setattr(command, "_handle_prompt_user", fake_handle_prompt_user)
with pytest.raises(CancelSignal, match="cancelled during confirmation"):
await command()
assert trace == ["prompt"]

View File

@@ -0,0 +1,121 @@
from prompt_toolkit.document import Document
from falyx import Falyx
from falyx.completer import FalyxCompleter
from falyx.parser import CommandArgumentParser
def completion_texts(completions) -> list[str]:
return [c.text for c in completions]
def make_completion_app() -> tuple[Falyx, FalyxCompleter]:
flx = Falyx(program="falyx")
flx.add_option(
"--profile",
suggestions=["dev", "prod", "staging"],
help="Runtime profile",
)
flx.add_option(
"--region",
choices=["us-east", "us-west"],
help="Deployment region",
)
parser = CommandArgumentParser()
parser.add_argument("--name")
parser.add_argument("--env", choices=["dev", "prod"])
flx.add_command(
key="D",
description="Deploy",
action=lambda name, env: f"deploy {name} to {env}",
aliases=["deploy"],
arg_parser=parser,
)
return flx, FalyxCompleter(flx)
def test_completion_suggests_namespace_flags():
_, completer = make_completion_app()
completions = list(
completer.get_completions(Document(text="--pr", cursor_position=4), None)
)
texts = completion_texts(completions)
assert "--profile" in texts
def test_completion_suggests_namespace_option_values():
_, completer = make_completion_app()
completions = list(
completer.get_completions(
Document(text="--profile pr", cursor_position=len("--profile pr")),
None,
)
)
texts = completion_texts(completions)
assert "prod" in texts
assert "dev" not in texts
def test_completion_after_committed_namespace_option_returns_namespace_entries():
_, completer = make_completion_app()
completions = list(
completer.get_completions(
Document(text="--profile prod de", cursor_position=len("--profile prod de")),
None,
)
)
texts = completion_texts(completions)
assert "deploy" in texts
def test_completion_preview_mode_prefixes_namespace_entry_suggestions():
_, completer = make_completion_app()
completions = list(
completer.get_completions(Document(text="?de", cursor_position=3), None)
)
texts = completion_texts(completions)
assert "?deploy" in texts
def test_resolve_completion_route_unresolved_entry_with_trailing_input_stops_namespace_entry_mode():
flx, _ = make_completion_app()
route = flx.resolve_completion_route(
["wat"],
stub="--na",
cursor_at_end_of_token=False,
invocation_context=flx.get_current_invocation_context(),
is_preview=False,
)
assert route.command is None
assert route.expecting_entry is False
assert route.remaining_argv == ["wat", "--na"]
assert route.stub == ""
def test_completion_delegates_to_command_parser_after_leaf_command_is_resolved():
_, completer = make_completion_app()
completions = list(
completer.get_completions(
Document(text="D --na", cursor_position=len("D --na")),
None,
)
)
texts = completion_texts(completions)
assert "--name" in texts

View File

@@ -0,0 +1,120 @@
import pytest
from falyx import Falyx
from falyx.routing import RouteKind, RouteResult
@pytest.mark.asyncio
async def test_dispatch_seeds_namespace_defaults_into_default_namespace(
monkeypatch,
):
flx = Falyx(program="falyx")
command = flx.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"])
route = RouteResult(
kind=RouteKind.COMMAND,
namespace=flx,
context=flx.get_current_invocation_context(),
command=command,
namespace_defaults={"region": "us-east"},
namespace_overrides={},
)
seen = {}
async def fake_execute(*, command, args, kwargs, execution_args, **_):
seen["region"] = flx.options_manager.get("region", None, "default")
return "ok"
monkeypatch.setattr(flx._executor, "execute", fake_execute)
result = await flx._dispatch_route(
route=route,
args=(),
kwargs={},
execution_args={},
)
assert result == "ok"
assert seen["region"] == "us-east"
assert flx.options_manager.get("region", None, "default") == "us-east"
@pytest.mark.asyncio
async def test_dispatch_applies_namespace_overrides_temporarily_in_default_namespace(
monkeypatch,
):
flx = Falyx(program="falyx")
command = flx.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"])
flx.options_manager.set("region", "us-east", "default")
route = RouteResult(
kind=RouteKind.COMMAND,
namespace=flx,
context=flx.get_current_invocation_context(),
command=command,
namespace_defaults={},
namespace_overrides={"region": "us-west"},
)
seen = {}
async def fake_execute(*, command, args, kwargs, execution_args, **_):
seen["region"] = flx.options_manager.get("region", None, "default")
return "ok"
monkeypatch.setattr(flx._executor, "execute", fake_execute)
result = await flx._dispatch_route(
route=route,
args=(),
kwargs={},
execution_args={},
raise_on_error=False,
wrap_errors=True,
)
assert result == "ok"
assert seen["region"] == "us-west"
assert flx.options_manager.get("region", None, "default") == "us-east"
@pytest.mark.asyncio
async def test_namespace_overrides_do_not_leak_after_command_execution(monkeypatch):
flx = Falyx(program="falyx")
command = flx.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"])
flx.options_manager.set("profile", "dev", "default")
route = RouteResult(
kind=RouteKind.COMMAND,
namespace=flx,
context=flx.get_current_invocation_context(),
command=command,
namespace_defaults={"region": "us-east"},
namespace_overrides={"profile": "prod"},
)
async def fake_execute(*, command, args, kwargs, execution_args, **_):
assert flx.options_manager.get("region", None, "default") == "us-east"
assert flx.options_manager.get("profile", None, "default") == "prod"
return "ok"
monkeypatch.setattr(flx._executor, "execute", fake_execute)
result = await flx._dispatch_route(
route=route,
args=(),
kwargs={},
execution_args={},
raise_on_error=False,
wrap_errors=True,
)
assert result == "ok"
assert flx.options_manager.get("region", None, "default") == "us-east"
assert flx.options_manager.get("profile", None, "default") == "dev"

View File

@@ -0,0 +1,68 @@
import pytest
from falyx.console import print_error
from falyx.exceptions import CommandArgumentError, MissingValueError
from falyx.parser import CommandArgumentParser
async def test_missing_value_error_has_user_facing_message():
parser = CommandArgumentParser()
parser.add_argument("--pair", type=int, nargs=2)
with pytest.raises(MissingValueError) as exc:
await parser.parse_args(["--pair", "1"])
assert "pair" in str(exc.value)
assert "expected" in str(exc.value).lower()
@pytest.mark.asyncio
async def test_missing_value_error_for_fixed_nargs_has_message_and_hint():
parser = CommandArgumentParser()
parser.add_argument("--pair", type=int, nargs=2)
with pytest.raises(MissingValueError) as exc:
await parser.parse_args(["--pair", "1"])
error = exc.value
assert str(error) == "missing values for '--pair': expected 2, got 1"
assert error.hint == "provide 2 values for '--pair'."
assert error.show_short_usage is True
assert error.dest == "pair"
@pytest.mark.asyncio
async def test_missing_value_error_for_plus_nargs_has_message_and_hint():
parser = CommandArgumentParser()
parser.add_argument("--items", nargs="+")
with pytest.raises(MissingValueError) as exc:
await parser.parse_args(["--items"])
error = exc.value
assert str(error) == "missing value for '--items'"
assert error.hint == "provide one or more values for '--items'."
def test_print_error_uses_exception_hint(monkeypatch) -> None:
printed: list[str] = []
class FakeConsole:
def print(self, value):
printed.append(value)
monkeypatch.setattr("falyx.console.error_console", FakeConsole())
error = CommandArgumentError(
"invalid command argument",
hint="use --help to see available options",
)
print_error(error)
assert any("error:" in line for line in printed)
assert any("invalid command argument" in line for line in printed)
assert any("hint:" in line for line in printed)
assert any("use --help to see available options" in line for line in printed)

View File

@@ -2,6 +2,8 @@ import pytest
from falyx import Falyx from falyx import Falyx
from falyx.action import Action from falyx.action import Action
from falyx.command_runner import CommandRunner
from falyx.parser import CommandArgumentParser
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -9,18 +11,32 @@ async def test_execute_command():
"""Test if Falyx can run in run key mode.""" """Test if Falyx can run in run key mode."""
falyx = Falyx("Run Key Test") falyx = Falyx("Run Key Test")
# Add a simple command
falyx.add_command( falyx.add_command(
key="T", key="T",
description="Test Command", description="Test Command",
action=lambda: "Hello, World!", action=lambda: "Hello, World!",
) )
# Run the CLI
result = await falyx.execute_command("T") result = await falyx.execute_command("T")
assert result == "Hello, World!" 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 @pytest.mark.asyncio
async def test_execute_command_recover(): async def test_execute_command_recover():
"""Test if Falyx can recover from a failure in run key mode.""" """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!") raise RuntimeError("Random failure!")
return "ok" return "ok"
# Add a command that raises an exception
falyx.add_command( falyx.add_command(
key="E", key="E",
description="Error Command", description="Error Command",
@@ -44,3 +59,66 @@ async def test_execute_command_recover():
result = await falyx.execute_command("E") result = await falyx.execute_command("E")
assert result == "ok" assert result == "ok"
@pytest.mark.asyncio
async def test_execute_command_with_argument_parsing():
"""Falyx.execute_command should parse command-local arguments before execution."""
falyx = Falyx("Argument Parsing Test")
falyx.add_command(
key="G",
description="Greet",
action=lambda name: f"hello {name}",
)
result = await falyx.execute_command("G Roland")
assert result == "hello Roland"
@pytest.mark.asyncio
async def test_command_runner_and_falyx_execute_same_command_with_same_result():
"""CommandRunner and Falyx should produce the same result for equivalent input."""
falyx = Falyx("Parity Test")
command = falyx.add_command(
key="G",
description="Greet",
action=lambda name: f"hello {name}",
aliases=["greet"],
)
runner = CommandRunner.from_command(command)
falyx_result = await falyx.execute_command("G Roland")
runner_result = await runner.run(["Roland"])
assert falyx_result == "hello Roland"
assert runner_result == "hello Roland"
assert falyx_result == runner_result
@pytest.mark.asyncio
async def test_command_runner_from_command_clones_and_preserves_parity():
"""Runner parity should hold even though from_command binds a clone."""
falyx = Falyx("Clone Parity Test")
parser = CommandArgumentParser()
parser.add_argument("x", type=int)
parser.add_argument("y", type=int)
command = falyx.add_command(
key="A",
description="Add",
action=lambda x, y: x + y,
arg_parser=parser,
)
runner = CommandRunner.from_command(command)
result_from_falyx = await falyx.execute_command("A 2 3")
result_from_runner = await runner.run(["2", "3"])
assert result_from_falyx == 5
assert result_from_runner == 5
assert runner.command is not command

View File

@@ -0,0 +1,856 @@
from __future__ import annotations
import asyncio
from contextlib import nullcontext
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from prompt_toolkit.validation import ValidationError
from rich.table import Table
from rich.text import Text
import falyx.falyx as falyx_module
from falyx import Falyx
from falyx.command import Command
from falyx.exceptions import (
CommandAlreadyExistsError,
CommandArgumentError,
EntryNotFoundError,
FalyxError,
InvalidActionError,
InvalidHookError,
NotAFalyxError,
UsageError,
)
from falyx.hook_manager import HookType
from falyx.mode import FalyxMode
from falyx.namespace import FalyxNamespace
from falyx.parser.parser_types import FalyxTLDRExample
from falyx.routing import RouteKind, RouteResult
from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
class RecordingConsole:
def __init__(self) -> None:
self.calls: list[tuple[tuple, dict]] = []
def print(self, *args, **kwargs) -> None:
self.calls.append((args, kwargs))
@property
def rendered(self) -> str:
parts: list[str] = []
for args, _ in self.calls:
if args:
value = args[0]
if isinstance(value, Text):
parts.append(value.plain)
else:
parts.append(str(value))
return "\n".join(parts)
def make_falyx(**overrides) -> Falyx:
defaults = {
"program": "fx",
"description": "Test CLI",
"enable_help_tips": False,
}
defaults.update(overrides)
return Falyx(**defaults)
def add_deploy(
flx: Falyx, *, key: str = "D", aliases: list[str] | None = None
) -> Command:
return flx.add_command(
key,
description="Deploy",
action=lambda: "deployed",
aliases=aliases if aliases is not None else ["deploy"],
help_text="Deploy things.",
)
def route_for(flx: Falyx, kind: RouteKind, **overrides) -> RouteResult:
values = {
"kind": kind,
"namespace": flx,
"context": flx.get_current_invocation_context(),
}
values.update(overrides)
return RouteResult(**values)
def test_init_with_prompt_history_sanitizes_program_name(tmp_path) -> None:
flx = Falyx(
program="my app.cli",
prompt_history_base_dir=tmp_path,
enable_prompt_history=True,
)
assert flx.history_path == tmp_path / ".my_app_history"
assert flx.history is not None
def test_str_and_repr_include_identity_fields() -> None:
flx = Falyx(program="fx", title="Deployments", description="Deploy CLI")
expected = "Falyx(program='fx', title='Deployments', description='Deploy CLI')"
assert str(flx) == expected
assert repr(flx) == expected
def test_add_tldr_examples_delegates_to_root_parser() -> None:
flx = make_falyx()
add_deploy(flx)
flx.add_tldr_examples([("deploy", "--region us-east", "Deploy east")])
assert flx.parser.tldr_option is not None
assert flx.parser._tldr_examples[-1].entry_key == "deploy"
def test_rejects_invalid_options_manager() -> None:
with pytest.raises(NotAFalyxError, match="options_manager"):
Falyx(options_manager=object())
def test_entry_map_rejects_identifier_collision_with_distinct_entries() -> None:
flx = make_falyx()
add_deploy(flx)
flx.namespaces["N"] = FalyxNamespace(
key="N",
description="Nested",
namespace=make_falyx(),
aliases=["Deploy"],
)
with pytest.raises(CommandAlreadyExistsError, match="identifier 'DEPLOY'"):
_ = flx._entry_map
def test_get_tip_adds_menu_specific_tips(monkeypatch) -> None:
flx = make_falyx()
flx.options_manager.set("mode", FalyxMode.MENU)
seen: dict[str, list[str]] = {}
def choose_last(tips: list[str]) -> str:
seen["tips"] = tips
return tips[-1]
monkeypatch.setattr(falyx_module, "choice", choose_last)
assert flx.get_tip() == "Use '[X]' in menu mode to exit."
assert "'[Y]' opens the command history viewer." in seen["tips"]
def test_command_key_usage_in_menu_includes_history_and_exit() -> None:
flx = make_falyx()
add_deploy(flx)
flx.options_manager.set("mode", FalyxMode.MENU)
usage = flx._get_command_keys_usage_string()
assert "D" in usage
assert "Y" in usage
assert "X" in usage
def test_simple_usage_mentions_namespace_when_visible_namespace_exists() -> None:
flx = make_falyx(simple_usage=True)
flx.add_submenu("OPS", "Operations", make_falyx())
fragment = flx._get_usage_fragment(flx.get_current_invocation_context())
assert "<command or namespace>" in fragment
def test_get_usage_omits_invocation_path_in_menu_mode() -> None:
flx = make_falyx(usage="custom [args]")
flx.options_manager.set("mode", FalyxMode.MENU)
usage = flx._get_usage()
assert usage == "[bold]usage:[/bold] [white]custom [args][/white]"
@pytest.mark.asyncio
async def test_render_command_tldr_prints_tip_when_examples_render(monkeypatch) -> None:
flx = make_falyx(enable_help_tips=True)
flx.console = RecordingConsole()
monkeypatch.setattr(flx, "get_tip", lambda: "remember aliases")
command = SimpleNamespace(
description="Deploy",
render_tldr=lambda invocation_context: True,
)
await flx._render_command_tldr(command)
assert "remember aliases" in flx.console.rendered
@pytest.mark.asyncio
async def test_render_command_tldr_prints_error_when_no_examples(monkeypatch) -> None:
flx = make_falyx()
messages: list[str] = []
monkeypatch.setattr(
falyx_module, "print_error", lambda message, **_: messages.append(str(message))
)
command = SimpleNamespace(
description="Deploy",
render_tldr=lambda invocation_context: False,
)
await flx._render_command_tldr(command)
assert messages == ["No TLDR examples available for 'Deploy'."]
@pytest.mark.asyncio
async def test_render_command_help_delegates_to_tldr(monkeypatch) -> None:
flx = make_falyx()
command = SimpleNamespace(description="Deploy")
context = flx.get_current_invocation_context()
called: dict[str, object] = {}
async def fake_tldr(rendered_command, invocation_context=None) -> None:
called["command"] = rendered_command
called["context"] = invocation_context
monkeypatch.setattr(flx, "_render_command_tldr", fake_tldr)
await flx._render_command_help(command, tldr=True, invocation_context=context)
assert called == {"command": command, "context": context}
@pytest.mark.asyncio
async def test_render_command_help_prints_tip_when_help_renders(monkeypatch) -> None:
flx = make_falyx(enable_help_tips=True)
flx.console = RecordingConsole()
monkeypatch.setattr(flx, "get_tip", lambda: "read the usage line")
command = SimpleNamespace(
description="Deploy",
render_help=lambda invocation_context: True,
)
await flx._render_command_help(command)
assert "read the usage line" in flx.console.rendered
@pytest.mark.asyncio
async def test_render_command_help_prints_error_when_no_help(monkeypatch) -> None:
flx = make_falyx()
messages: list[str] = []
monkeypatch.setattr(
falyx_module, "print_error", lambda message, **_: messages.append(str(message))
)
command = SimpleNamespace(
description="Deploy",
render_help=lambda invocation_context: False,
)
await flx._render_command_help(command)
assert messages == ["No detailed help available for 'Deploy'."]
@pytest.mark.asyncio
async def test_render_tag_help_prints_empty_tag_message() -> None:
flx = make_falyx()
flx.console = RecordingConsole()
await flx._render_tag_help("missing")
assert "Nothing to show here" in flx.console.rendered
@pytest.mark.asyncio
async def test_render_menu_help_includes_namespaces_and_epilog(monkeypatch) -> None:
monkeypatch.setattr(
FalyxNamespace,
"get_help_signature",
lambda self, context: (self.key, self.description, ""),
raising=False,
)
flx = make_falyx(epilog="Menu epilog")
flx.console = RecordingConsole()
flx.add_submenu("OPS", "Operations namespace", make_falyx())
await flx._render_menu_help(flx.get_current_invocation_context())
assert "namespaces" in flx.console.rendered
assert "Menu epilog" in flx.console.rendered
@pytest.mark.asyncio
async def test_render_cli_help_includes_namespaces_aliases_and_epilog() -> None:
flx = make_falyx(epilog="CLI epilog")
flx.console = RecordingConsole()
flx.add_submenu("OPS", "Operations namespace", make_falyx(), aliases=["operations"])
await flx._render_cli_help(flx.get_current_invocation_context())
rendered = flx.console.rendered
assert "namespaces" in rendered
assert "OPS | operations" in rendered
assert "Operations namespace" in rendered
assert "CLI epilog" in rendered
@pytest.mark.asyncio
async def test_namespace_tldr_prints_empty_message_without_examples() -> None:
flx = make_falyx(title="Root Menu")
flx.console = RecordingConsole()
await flx._render_namespace_tldr_help(flx.get_current_invocation_context())
assert "No TLDR examples available for 'Root Menu'" in flx.console.rendered
@pytest.mark.asyncio
async def test_namespace_tldr_rejects_stale_unknown_example() -> None:
flx = make_falyx()
flx.parser.tldr_option = object()
flx.parser._tldr_examples.append(
FalyxTLDRExample(
entry_key="missing",
usage="",
description="Stale example",
)
)
with pytest.raises(EntryNotFoundError) as error:
await flx._render_namespace_tldr_help(flx.get_current_invocation_context())
assert error.value.unknown_name == "missing"
def test_help_target_base_context_handles_empty_and_help_command_path() -> None:
flx = make_falyx()
base_context = flx.get_current_invocation_context()
assert flx._help_target_base_context(base_context) is base_context
help_context = base_context.with_path_segment("H", style=flx.help_command.style)
stripped = flx._help_target_base_context(help_context)
assert stripped.typed_path == []
@pytest.mark.asyncio
async def test_render_help_dispatches_to_specific_command(monkeypatch) -> None:
flx = make_falyx()
command = add_deploy(flx)
called: dict[str, object] = {}
async def fake_command_help(command, tldr=False, invocation_context=None) -> None:
called["command"] = command
called["tldr"] = tldr
called["path"] = list(invocation_context.typed_path)
monkeypatch.setattr(flx, "_render_command_help", fake_command_help)
await flx.render_help(key="deploy", tldr=True)
assert called == {"command": command, "tldr": True, "path": ["deploy"]}
@pytest.mark.asyncio
async def test_render_help_dispatches_to_specific_namespace(monkeypatch) -> None:
flx = make_falyx()
submenu = make_falyx()
called: dict[str, object] = {}
flx.add_submenu("OPS", "Operations", submenu)
async def fake_namespace_help(invocation_context=None, tldr=False) -> None:
called["tldr"] = tldr
called["path"] = list(invocation_context.typed_path)
monkeypatch.setattr(submenu, "render_namespace_help", fake_namespace_help)
await flx.render_help(key="OPS", tldr=True)
assert called == {"tldr": True, "path": ["OPS"]}
@pytest.mark.asyncio
async def test_render_help_renders_namespace_then_raises_for_unknown_key(
monkeypatch,
) -> None:
flx = make_falyx()
rendered: list[InvocationContext] = []
async def fake_namespace_help(invocation_context=None, tldr=False) -> None:
rendered.append(invocation_context)
monkeypatch.setattr(flx, "render_namespace_help", fake_namespace_help)
with pytest.raises(EntryNotFoundError) as error:
await flx.render_help(key="depoy")
assert rendered
assert error.value.unknown_name == "depoy"
@pytest.mark.asyncio
async def test_render_help_without_key_tldr_renders_help_command_tldr(
monkeypatch,
) -> None:
flx = make_falyx()
called: dict[str, object] = {}
async def fake_command_help(command, tldr=False, invocation_context=None) -> None:
called["command"] = command
called["tldr"] = tldr
monkeypatch.setattr(flx, "_render_command_help", fake_command_help)
await flx.render_help(tldr=True)
assert called == {"command": flx.help_command, "tldr": True}
@pytest.mark.asyncio
async def test_preview_rejects_namespaces_and_unknown_entries() -> None:
flx = make_falyx()
flx.add_submenu("OPS", "Operations", make_falyx())
with pytest.raises(FalyxError, match="preview mode"):
await flx._preview("OPS")
with pytest.raises(EntryNotFoundError) as error:
await flx._preview("missing")
assert error.value.unknown_name == "missing"
@pytest.mark.asyncio
async def test_render_version_prints_program_version() -> None:
flx = make_falyx(program="fx", version="9.9.9")
flx.console = RecordingConsole()
await flx._render_version()
assert "fx v9.9.9" in flx.console.rendered
def test_invalidate_prompt_session_cache_deletes_cached_property_value() -> None:
flx = make_falyx()
flx.__dict__["prompt_session"] = object()
flx._prompt_session = object()
flx._invalidate_prompt_session_cache()
assert "prompt_session" not in flx.__dict__
assert flx._prompt_session is None
def test_bottom_bar_accepts_instance_string_callable_and_rejects_invalid() -> None:
flx = make_falyx()
existing_bottom_bar = flx.bottom_bar
flx.bottom_bar = existing_bottom_bar
assert flx.bottom_bar is existing_bottom_bar
assert flx.bottom_bar.key_bindings is flx.key_bindings
flx.bottom_bar = "static toolbar"
assert flx._get_bottom_bar_render() == "static toolbar"
renderer = lambda: "dynamic toolbar"
flx.bottom_bar = renderer
assert flx._get_bottom_bar_render() is renderer
with pytest.raises(FalyxError, match="bottom_bar"):
flx.bottom_bar = object()
def test_default_bottom_bar_render_is_returned_when_items_exist() -> None:
flx = make_falyx()
render = flx._get_bottom_bar_render()
if flx.bottom_bar.has_items:
assert render is flx.bottom_bar.render
else:
assert render is None
def test_register_all_hooks_rejects_non_callable_hook() -> None:
flx = make_falyx()
with pytest.raises(InvalidHookError, match="callable"):
flx.register_all_hooks(HookType.BEFORE, object())
def test_validate_command_aliases_rejects_duplicate_aliases() -> None:
flx = make_falyx()
with pytest.raises(CommandAlreadyExistsError, match="duplicate aliases"):
flx.add_command(
"D", description="Deploy", action=lambda: None, aliases=["deploy", "DEPLOY"]
)
def test_validate_command_aliases_rejects_key_as_alias() -> None:
flx = make_falyx()
with pytest.raises(CommandAlreadyExistsError, match="cannot also be an alias"):
flx.add_command("D", description="Deploy", action=lambda: None, aliases=["D"])
def test_validate_command_aliases_rejects_existing_identifier_collision() -> None:
flx = make_falyx()
with pytest.raises(CommandAlreadyExistsError, match="already exist"):
flx.add_command("H", description="Duplicate Help", action=lambda: None)
def test_update_exit_command_rejects_non_callable_action() -> None:
flx = make_falyx()
with pytest.raises(InvalidActionError, match="callable"):
flx.update_exit_command(key="Q", action="quit")
def test_add_submenu_rejects_non_falyx_submenu() -> None:
flx = make_falyx()
with pytest.raises(NotAFalyxError, match="submenu"):
flx.add_submenu("OPS", "Operations", object())
def test_add_commands_accepts_dicts_and_command_instances() -> None:
flx = make_falyx()
reusable = Command(key="B", description="Build", action=lambda: "built")
commands = flx.add_commands(
[
{"key": "D", "description": "Deploy", "action": lambda: "deployed"},
reusable,
]
)
assert [command.key for command in commands] == ["D", "B"]
assert flx.commands["D"].description == "Deploy"
assert flx.commands["B"].description == "Build"
def test_add_commands_rejects_invalid_items() -> None:
flx = make_falyx()
with pytest.raises(FalyxError, match="dictionary or an instance of Command"):
flx.add_commands([object()])
def test_add_command_from_command_rejects_non_command() -> None:
flx = make_falyx()
with pytest.raises(FalyxError, match="instance of Command"):
flx.add_command_from_command(object())
def test_iter_visible_entries_can_include_builtins() -> None:
flx = make_falyx()
visible = flx._iter_visible_entries(include_builtins=True)
assert any(entry.key == "H" for entry in visible)
assert any(entry.key == "PVW" for entry in visible)
assert any(entry.key == "VER" for entry in visible)
def test_build_placeholder_menu_returns_empty_placeholder_without_user_commands() -> None:
flx = make_falyx()
assert flx.build_placeholder_menu() == [("", "")]
def test_table_uses_callable_custom_table_and_rejects_invalid_factory() -> None:
good = make_falyx(custom_table=lambda app: Table(title=app.title))
assert isinstance(good.table, Table)
bad = make_falyx(custom_table=lambda app: "not a table")
with pytest.raises(FalyxError, match="custom_table"):
_ = bad.table
def test_table_uses_prebuilt_custom_table_instance() -> None:
table = Table(title="Prebuilt")
flx = make_falyx(custom_table=table)
assert flx.table is table
def test_resolve_entry_accepts_unique_prefix_matches() -> None:
flx = make_falyx()
command = add_deploy(flx, key="DEPLOY", aliases=[])
entry, suggestions = flx.resolve_entry("depl")
assert entry is command
assert suggestions == []
@pytest.mark.asyncio
async def test_prepare_route_converts_bad_shell_string_to_validation_error() -> None:
flx = make_falyx()
with pytest.raises(ValidationError):
await flx.prepare_route('"unterminated', from_validate=True)
@pytest.mark.asyncio
async def test_prepare_route_converts_bad_shell_string_to_usage_error() -> None:
flx = make_falyx()
with pytest.raises(UsageError, match="No closing quotation"):
await flx.prepare_route('"unterminated')
@pytest.mark.asyncio
async def test_prepare_route_rejects_invalid_raw_argument_type() -> None:
flx = make_falyx()
with pytest.raises(AssertionError, match="Validator can only pass"):
await flx.prepare_route(object(), from_validate=True)
with pytest.raises(UsageError, match="raw_arguments"):
await flx.prepare_route(object())
@pytest.mark.asyncio
async def test_prepare_route_preserves_preview_route_without_resolving_command_args() -> (
None
):
flx = make_falyx()
add_deploy(flx)
route, args, kwargs, execution_args = await flx.prepare_route("?D")
assert route.is_preview is True
assert route.kind is RouteKind.COMMAND
assert args == ()
assert kwargs == {}
assert execution_args == {}
@pytest.mark.asyncio
async def test_prepare_route_wraps_route_errors_for_validation(monkeypatch) -> None:
flx = make_falyx()
async def fake_resolve_route(*args, **kwargs):
raise FalyxError("bad route", hint="try deploy")
monkeypatch.setattr(flx, "resolve_route", fake_resolve_route)
with pytest.raises(ValidationError) as error:
await flx.prepare_route("D", from_validate=True)
assert "try deploy" in str(error.value)
@pytest.mark.asyncio
async def test_prepare_route_wraps_command_argument_errors_for_validation(
monkeypatch,
) -> None:
flx = make_falyx()
command = add_deploy(flx)
async def fake_resolve_route(*args, **kwargs):
return route_for(flx, RouteKind.COMMAND, command=command, leaf_argv=["--bad"])
async def fake_resolve_args(*args, **kwargs):
raise CommandArgumentError("bad args", hint="use --help")
monkeypatch.setattr(flx, "resolve_route", fake_resolve_route)
monkeypatch.setattr(Command, "resolve_args", fake_resolve_args)
with pytest.raises(ValidationError) as error:
await flx.prepare_route(["D"], from_validate=True)
assert "use --help" in str(error.value)
@pytest.mark.asyncio
async def test_render_unknown_route_rejects_preview_namespace_menu() -> None:
flx = make_falyx()
route = route_for(flx, RouteKind.NAMESPACE_MENU)
with pytest.raises(FalyxError, match="preview mode"):
await flx._render_unknown_route(route)
@pytest.mark.asyncio
async def test_dispatch_route_previews_command_and_unknown_preview(monkeypatch) -> None:
flx = make_falyx()
command = SimpleNamespace(key="D", preview=AsyncMock())
command_route = route_for(
flx,
RouteKind.COMMAND,
command=command,
is_preview=True,
)
await flx._dispatch_route(route=command_route)
command.preview.assert_awaited_once()
unknown_route = route_for(
flx, RouteKind.UNKNOWN, current_head="missing", is_preview=True
)
rendered: list[RouteResult] = []
async def fake_unknown(route):
rendered.append(route)
monkeypatch.setattr(flx, "_render_unknown_route", fake_unknown)
await flx._dispatch_route(route=unknown_route)
assert rendered == [unknown_route]
@pytest.mark.asyncio
async def test_dispatch_route_unknown_returns_after_rendering(monkeypatch) -> None:
flx = make_falyx()
route = route_for(flx, RouteKind.UNKNOWN, current_head="missing")
rendered: list[RouteResult] = []
async def fake_unknown(route):
rendered.append(route)
monkeypatch.setattr(flx, "_render_unknown_route", fake_unknown)
assert await flx._dispatch_route(route=route) is None
assert rendered == [route]
@pytest.mark.asyncio
async def test_dispatch_route_rejects_command_route_without_command() -> None:
flx = make_falyx()
route = route_for(flx, RouteKind.COMMAND, command=None)
with pytest.raises(FalyxError, match="command expected"):
await flx._dispatch_route(route=route)
@pytest.mark.asyncio
async def test_execute_command_requires_error_policy() -> None:
flx = make_falyx()
with pytest.raises(FalyxError, match="requires either"):
await flx.execute_command("D", raise_on_error=False, wrap_errors=False)
def test_resolve_completion_route_returns_entry_completion_for_unknown_committed_token() -> (
None
):
flx = make_falyx()
context = flx.get_current_invocation_context()
route = flx.resolve_completion_route(
["depoy"],
stub="",
cursor_at_end_of_token=False,
invocation_context=context,
)
assert route.expecting_entry is True
assert route.stub == "depoy"
assert route.command is None
@pytest.mark.asyncio
async def test_process_command_executes_prompt_input_and_reports_falyx_error(
monkeypatch,
) -> None:
flx = make_falyx()
errors: list[object] = []
invalidated: list[bool] = []
class FakeApp:
def invalidate(self) -> None:
invalidated.append(True)
class FakeSession:
async def prompt_async(self) -> str:
return "D"
async def fake_execute_command(*args, **kwargs):
raise FalyxError("boom")
monkeypatch.setattr(falyx_module, "get_app", lambda: FakeApp())
monkeypatch.setattr(falyx_module.asyncio, "sleep", AsyncMock())
monkeypatch.setattr(falyx_module, "patch_stdout", lambda raw=True: nullcontext())
monkeypatch.setattr(
falyx_module, "print_error", lambda message, **_: errors.append(message)
)
monkeypatch.setattr(flx, "execute_command", fake_execute_command)
flx.__dict__["prompt_session"] = FakeSession()
await flx._process_command()
assert invalidated == [True]
assert isinstance(errors[0], FalyxError)
@pytest.mark.asyncio
async def test_menu_handles_flow_signals_and_prints_welcome_and_exit(monkeypatch) -> None:
rendered: list[Falyx] = []
flx = make_falyx(
welcome_message="welcome",
exit_message="goodbye",
render_menu=lambda app: rendered.append(app),
)
flx.console = RecordingConsole()
signals: list[BaseException] = [
HelpSignal(),
BackSignal(),
CancelSignal(),
asyncio.CancelledError(),
QuitSignal(),
]
async def fake_process_command() -> None:
raise signals.pop(0)
monkeypatch.setattr(flx, "_process_command", fake_process_command)
await flx.menu()
assert rendered == [flx, flx, flx, flx, flx]
assert "welcome" in flx.console.rendered
assert "goodbye" in flx.console.rendered
@pytest.mark.asyncio
async def test_run_logs_verbose_unhandled_errors_before_exit(monkeypatch) -> None:
flx = make_falyx()
flx.options_manager.set("verbose", True, "root")
context = flx.get_current_invocation_context()
route = RouteResult(kind=RouteKind.NAMESPACE_MENU, namespace=flx, context=context)
logged: list[tuple[tuple, dict]] = []
async def fake_prepare_route(*args, **kwargs):
return route, (), {}, {}
async def fake_dispatch_route(*args, **kwargs):
raise FalyxError("boom")
monkeypatch.setattr(flx, "prepare_route", fake_prepare_route)
monkeypatch.setattr(flx, "_dispatch_route", fake_dispatch_route)
monkeypatch.setattr(falyx_module.sys, "argv", ["fx", "D"])
monkeypatch.setattr(falyx_module, "print_error", lambda message, **_: None)
monkeypatch.setattr(
falyx_module.logger,
"error",
lambda *args, **kwargs: logged.append((args, kwargs)),
)
with pytest.raises(SystemExit) as error:
await flx.run()
assert error.value.code == 1
assert logged
assert logged[0][1]["exc_info"] is True

View File

@@ -90,6 +90,6 @@ async def test_help_command_bad_argument(capsys):
flx.add_command("U", "Untagged Command", untagged_command) flx.add_command("U", "Untagged Command", untagged_command)
with pytest.raises( with pytest.raises(
CommandArgumentError, match="Unexpected positional argument: nonexistent_tag" CommandArgumentError, match="unexpected positional argument: nonexistent_tag"
): ):
await flx.execute_command("H nonexistent_tag") await flx.execute_command("H nonexistent_tag")

View File

@@ -0,0 +1,219 @@
import pytest
from falyx import Falyx
from falyx.action import Action, ChainedAction
from falyx.command import Command
from falyx.options_manager import OptionsManager
def test_seed_missing_and_override_namespace_do_not_leak():
options = OptionsManager()
options.set("verbose", True, "root")
options.seed_missing({"verbose": False, "debug_hooks": False}, "root")
assert options.get("verbose", namespace_name="root") is True
assert options.get("debug_hooks", namespace_name="root") is False
with options.override_namespace({"verbose": False}, "root"):
assert options.get("verbose", namespace_name="root") is False
assert options.get("verbose", namespace_name="root") is True
def test_command_and_action_read_options_from_expected_namespace():
options = OptionsManager()
options.from_mapping({"region": "us-east"}, "default")
options.from_mapping({"never_prompt": True, "verbose": True}, "root")
action = Action("deploy-action", lambda: "ok")
command = Command.build(
key="D",
description="Deploy",
action=action,
options_manager=options,
)
command._inject_options_manager()
assert command.get_option("region") == "us-east"
assert command.get_option("verbose", namespace_name="root") is True
assert action.get_option("region") == "us-east"
assert action.get_option("verbose", namespace_name="root") is True
assert action.never_prompt is True
assert action.local_never_prompt is None
def test_all_objects_in_one_namespace_share_same_options_manager():
flx = Falyx(program="root")
chain = ChainedAction(
name="deploy-flow",
actions=[
Action("step-one", lambda: "one"),
Action("step-two", lambda: "two"),
],
)
command = flx.add_command("D", "Deploy", action=chain)
command._inject_options_manager()
assert flx._executor.options_manager is flx.options_manager
assert flx.exit_command.options_manager is flx.options_manager
assert flx.help_command.options_manager is flx.options_manager
if flx.history_command:
assert flx.history_command.options_manager is flx.options_manager
assert flx.history_command.arg_parser.options_manager is flx.options_manager
for builtin in flx.builtins.values():
assert builtin.options_manager is flx.options_manager
if builtin.arg_parser:
assert builtin.arg_parser.options_manager is flx.options_manager
assert command.options_manager is flx.options_manager
assert command.arg_parser.options_manager is flx.options_manager
assert chain.options_manager is flx.options_manager
for child_action in chain.actions:
assert child_action.options_manager is flx.options_manager
def test_nested_namespace_may_keep_distinct_options_manager_if_intended():
root_options = OptionsManager()
child_options = OptionsManager()
root = Falyx(program="root", options_manager=root_options)
child = Falyx(program="child", options_manager=child_options)
child_command = child.add_command("D", "Deploy", action=lambda: "ok")
root.add_submenu(
key="C",
description="Child Menu",
submenu=child,
)
assert root.options_manager is root_options
assert child.options_manager is child_options
assert root.options_manager is not child.options_manager
assert root._executor.options_manager is root_options
assert child._executor.options_manager is child_options
assert child_command.options_manager is child_options
assert child_command.arg_parser.options_manager is child_options
assert child.exit_command.options_manager is child_options
assert child.help_command.options_manager is child_options
if child.history_command:
assert child.history_command.options_manager is child_options
assert root.namespaces["C"].namespace is child
@pytest.mark.asyncio
async def test_nested_namespace_receives_temporary_root_overrides_during_routed_execution():
root_options = OptionsManager()
child_options = OptionsManager()
root = Falyx(program="root", options_manager=root_options)
child = Falyx(program="child", options_manager=child_options)
child.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"])
child.options_manager.set("verbose", False, "root")
root.add_submenu(
key="C",
description="Child Menu",
submenu=child,
)
seen_during_dispatch = {}
async def fake_dispatch_route(*, route, args, kwargs, execution_args, **_):
assert route.namespace is child
seen_during_dispatch["verbose"] = route.namespace.options_manager.get(
"verbose", False, "root"
)
assert seen_during_dispatch["verbose"] is True
return "ok"
root._dispatch_route = fake_dispatch_route
result = await root.execute_command("--verbose C D")
assert result == "ok"
assert seen_during_dispatch["verbose"] is True
result = await root.execute_command("C --verbose D")
assert result == "ok"
assert seen_during_dispatch["verbose"] is True
assert child.options_manager is child_options
assert child.options_manager.get("verbose", False, "root") is False
assert root.options_manager is root_options
@pytest.mark.asyncio
async def test_execute_command_applies_root_defaults_without_overwriting_existing_root_values():
child = Falyx(program="child")
child.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"])
child.options_manager.set("verbose", True, "root")
root = Falyx(program="root")
root.add_submenu(
key="C",
description="Child Menu",
submenu=child,
)
async def fake_dispatch_route(*, route, args, kwargs, execution_args, **_):
assert route.namespace is child
assert route.root_overrides == {}
assert route.root_defaults["verbose"] is False
assert route.namespace.options_manager.get("verbose", False, "root") is True
return "ok"
root._dispatch_route = fake_dispatch_route
result = await root.execute_command("C D")
assert result == "ok"
assert child.options_manager.get("verbose", False, "root") is True
@pytest.mark.asyncio
async def test_execute_command_applies_root_overrides_temporarily_and_restores_root_namespace():
child = Falyx(program="child")
child.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"])
child.options_manager.set("verbose", False, "root")
root = Falyx(program="root")
root.add_submenu(
key="C",
description="Child Menu",
submenu=child,
)
seen_during_dispatch = {}
async def fake_dispatch_route(*, route, args, kwargs, execution_args, **_):
assert route.namespace is child
assert route.root_overrides == {"verbose": True}
seen_during_dispatch["verbose"] = route.namespace.options_manager.get(
"verbose", False, "root"
)
assert seen_during_dispatch["verbose"] is True
return "ok"
root._dispatch_route = fake_dispatch_route
result = await root.execute_command("--verbose C D")
assert result == "ok"
assert seen_during_dispatch["verbose"] is True
assert child.options_manager.get("verbose", False, "root") is False

View File

@@ -0,0 +1,21 @@
from falyx.action import Action
from falyx.command import Command
async def test_action_local_never_prompt_bypasses_command_confirmation(monkeypatch):
called = False
async def fake_confirm(*args, **kwargs):
nonlocal called
called = True
return True
monkeypatch.setattr("falyx.command.confirm_async", fake_confirm)
action = Action("Do Thing", lambda: "ok", never_prompt=True)
command = Command.build("D", "Do Thing", action=action, confirm=True)
result = await command()
assert result == "ok"
assert called is False

View File

@@ -0,0 +1,92 @@
import pytest
from falyx import Falyx
from falyx.routing import RouteKind
@pytest.mark.asyncio
async def test_resolve_route_carries_root_options_through_nested_namespace():
child = Falyx(program="child")
child.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"])
root = Falyx(program="root")
root.add_submenu(
key="C",
description="Child Menu",
submenu=child,
)
route = await root.resolve_route(
["--verbose", "C", "D"],
invocation_context=root.get_current_invocation_context(),
)
assert route.context.typed_path[-2:] == ["C", "D"]
assert route.kind is RouteKind.COMMAND
assert route.namespace is child
assert route.command is child.commands["D"]
assert route.leaf_argv == []
assert route.root_overrides == {"verbose": True}
assert route.root_defaults["verbose"] is False
assert route.root_defaults["debug_hooks"] is False
assert route.root_defaults["never_prompt"] is False
assert route.namespace_overrides == {}
@pytest.mark.asyncio
async def test_resolve_route_returns_unknown_when_only_namespace_options_are_provided():
flx = Falyx(program="falyx")
flx.add_option("--profile", default="dev")
route = await flx.resolve_route(
["--profile", "prod"],
invocation_context=flx.get_current_invocation_context(),
)
assert route.kind is RouteKind.UNKNOWN
assert route.namespace is flx
assert route.command is None
assert route.current_head == ""
assert route.is_preview is False
assert route.root_defaults == {}
assert route.root_overrides == {}
assert route.namespace_defaults == {}
assert route.namespace_overrides == {}
@pytest.mark.asyncio
async def test_resolve_route_returns_unknown_when_only_root_options_are_provided():
flx = Falyx(program="falyx")
route = await flx.resolve_route(
["--verbose"],
invocation_context=flx.get_current_invocation_context(),
)
assert route.kind is RouteKind.UNKNOWN
assert route.namespace is flx
assert route.command is None
assert route.current_head == ""
assert route.is_preview is False
@pytest.mark.asyncio
async def test_resolve_route_returns_unknown_when_nested_namespace_consumes_only_options():
child = Falyx(program="child")
child.add_option("--region", default="us-east")
root = Falyx(program="root")
root.add_submenu(key="C", description="Child", submenu=child)
route = await root.resolve_route(
["C", "--region", "us-west"],
invocation_context=root.get_current_invocation_context(),
)
assert route.kind is RouteKind.UNKNOWN
assert route.namespace is child
assert route.command is None
assert route.context.typed_path[-1] == "C"

View File

@@ -7,7 +7,6 @@ from rich.text import Text
from falyx import Falyx from falyx import Falyx
from falyx.console import console as falyx_console from falyx.console import console as falyx_console
from falyx.exceptions import FalyxError from falyx.exceptions import FalyxError
from falyx.parser import ParseResult
from falyx.signals import BackSignal, CancelSignal, FlowSignal, HelpSignal, QuitSignal 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): async def test_run_debug_hooks(flx):
sys.argv = ["falyx", "--debug-hooks", "T"] 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): with pytest.raises(SystemExit):
await flx.run() 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 @pytest.mark.asyncio
async def test_run_never_prompt(flx): async def test_run_never_prompt(flx):
sys.argv = ["falyx", "--never-prompt", "T"] 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): with pytest.raises(SystemExit):
await flx.run() await flx.run()
falyx_console.print(flx.options.get_namespace_dict("default")) assert flx.options_manager.get("debug_hooks", namespace_name="root") is False
assert flx.options_manager.get("never_prompt", namespace_name="root") is False
assert flx.options.get("debug_hooks") is False
assert flx.options.get("never_prompt") is True
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -253,3 +250,70 @@ async def test_run_preview(flx):
captured = Text.from_ansi(capture.get()).plain captured = Text.from_ansi(capture.get()).plain
assert "Command: 'T'" in captured assert "Command: 'T'" in captured
assert "Would call: <lambda>(args=(), kwargs={})" in captured assert "Would call: <lambda>(args=(), kwargs={})" in captured
@pytest.mark.asyncio
async def test_run_applies_root_defaults_without_overwriting_existing_root_values():
child = Falyx(program="child")
child.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"])
child.options_manager.set("verbose", True, "root")
root = Falyx(program="root")
root.add_submenu(
key="C",
description="Child Menu",
submenu=child,
)
async def fake_dispatch_route(*, route, args, kwargs, execution_args, **_):
assert route.namespace is child
assert route.root_overrides == {}
assert route.root_defaults["verbose"] is False
assert route.namespace.options_manager.get("verbose", False, "root") is True
root._dispatch_route = fake_dispatch_route
sys.argv = ["falyx", "C", "D"]
with pytest.raises(SystemExit) as excinfo:
await root.run()
assert excinfo.value.code == 0
assert child.options_manager.get("verbose", False, "root") is True
@pytest.mark.asyncio
async def test_run_applies_root_overrides_temporarily_and_restores_root_namespace():
child = Falyx(program="child")
child.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"])
child.options_manager.set("verbose", False, "root")
root = Falyx(program="root")
root.add_submenu(
key="C",
description="Child Menu",
submenu=child,
)
seen_during_dispatch = {}
async def fake_dispatch_route(*, route, args, kwargs, execution_args, **_):
seen_during_dispatch["verbose"] = route.namespace.options_manager.get(
"verbose", False, "root"
)
assert route.namespace is child
assert route.root_overrides == {"verbose": True}
assert seen_during_dispatch["verbose"] is True
root._dispatch_route = fake_dispatch_route
sys.argv = ["falyx", "--verbose", "C", "D"]
with pytest.raises(SystemExit) as excinfo:
await root.run()
assert excinfo.value.code == 0
assert seen_during_dispatch["verbose"] is True
assert child.options_manager.get("verbose", False, "root") is False

View File

@@ -0,0 +1,15 @@
import sys
import pytest
from falyx import Falyx
async def test_run_quit_signal_exits_130(monkeypatch):
flx = Falyx(default_to_menu=False)
monkeypatch.setattr(sys, "argv", ["prog", "X"])
with pytest.raises(SystemExit) as exc:
await flx.run()
assert exc.value.code == 130

View File

@@ -0,0 +1,900 @@
from __future__ import annotations
import pytest
from falyx import Falyx
from falyx.exceptions import EntryNotFoundError, FalyxOptionError
from falyx.mode import FalyxMode
from falyx.parser.falyx_parser import FalyxParser
from falyx.parser.option import Option
from falyx.parser.parser_types import FalyxTLDRExample
@pytest.fixture
def flx() -> Falyx:
flx = Falyx()
flx.add_command(
"D",
description="Deploy command",
action=lambda: "deploy",
aliases=["deploy"],
)
return flx
@pytest.fixture
def parser(flx: Falyx) -> FalyxParser:
return FalyxParser(flx)
def test_init_registers_reserved_options_by_default(parser: FalyxParser) -> None:
flags = parser.get_flags()
assert "-h" in flags
assert "-v" in flags
assert "-d" in flags
assert "-n" in flags
assert parser.help_option is not None
assert parser.tldr_option is None
def test_init_respects_disabled_reserved_root_options() -> None:
parser = FalyxParser(
Falyx(
disable_verbose_option=True,
disable_debug_hooks_option=True,
disable_never_prompt_option=True,
)
)
assert parser.get_flags() == ["-h"]
with pytest.raises(FalyxOptionError, match="unknown option '-v'"):
parser.parse_args(["-v"])
def test_get_options_returns_registered_options(parser: FalyxParser) -> None:
parser.add_option("--region", "-r", default="us-east")
options = parser.get_options()
assert any(option.dest == "region" for option in options)
assert parser.get_flags()[-1] == "--region"
def test_add_option_registers_store_option_with_default_and_choices(
parser: FalyxParser,
) -> None:
parser.add_option(
"--region",
"-r",
default="us-east",
choices=["us-east", "us-west"],
)
result = parser.parse_args(["--region", "us-west", "deploy"])
assert result.namespace_options["region"] == "us-west"
assert result.namespace_defaults["region"] == "us-east"
assert result.remaining_argv == ["deploy"]
def test_add_option_infers_dest_from_long_flag(parser: FalyxParser) -> None:
parser.add_option("--dry-run-mode", default="safe")
result = parser.parse_args([])
assert result.namespace_defaults["dry_run_mode"] == "safe"
def test_add_option_uses_explicit_dest(parser: FalyxParser) -> None:
parser.add_option("--profile-name", dest="profile", default="dev")
result = parser.parse_args(["--profile-name", "prod"])
assert result.namespace_options["profile"] == "prod"
@pytest.mark.parametrize(
("flags", "match"),
[
((), "no flags provided"),
(("region",), "must start with '-'"),
(("--",), "long flags must have at least one character"),
(("-abc",), "short flags must be a single character"),
],
)
def test_add_option_rejects_invalid_flags(
parser: FalyxParser,
flags: tuple[str, ...],
match: str,
) -> None:
with pytest.raises(FalyxOptionError, match=match):
parser.add_option(*flags)
@pytest.mark.parametrize("dest", ["help", "tldr"])
def test_add_option_rejects_reserved_dests(
parser: FalyxParser,
dest: str,
) -> None:
with pytest.raises(FalyxOptionError, match="reserved"):
parser.add_option("--custom", dest=dest)
def test_add_option_rejects_duplicate_dest(parser: FalyxParser) -> None:
parser.add_option("--region")
with pytest.raises(FalyxOptionError, match="duplicate option dest 'region'"):
parser.add_option("--region-name", dest="region")
def test_add_option_rejects_duplicate_flag(parser: FalyxParser) -> None:
parser.add_option("--region")
with pytest.raises(FalyxOptionError, match="already used"):
parser.add_option("--region", dest="other_region")
@pytest.mark.parametrize(
("dest", "match"),
[
("bad-dest", "valid identifier"),
("1bad", "cannot start with a digit"),
],
)
def test_add_option_rejects_invalid_explicit_dest(
parser: FalyxParser,
dest: str,
match: str,
) -> None:
with pytest.raises(FalyxOptionError, match=match):
parser.add_option("--valid", dest=dest)
def test_add_option_rejects_invalid_action(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="invalid option action"):
parser.add_option("--region", action="not-real")
def test_add_option_rejects_invalid_store_true_default(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="must be False or None"):
parser.add_option("--foo", action="store_true", default=True)
def test_add_option_rejects_invalid_store_false_default(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="must be True or None"):
parser.add_option("--foo", action="store_false", default=False)
def test_add_option_rejects_invalid_store_bool_optional_default(
parser: FalyxParser,
) -> None:
with pytest.raises(
FalyxOptionError,
match="default value for 'store_bool_optional' action must be None",
):
parser.add_option("--foo", action="store_bool_optional", default="not-bool")
def test_add_option_rejects_default_for_help_or_tldr_option(parser: FalyxParser) -> None:
with pytest.raises(
FalyxOptionError, match="default value cannot be set for action 'help'"
):
parser.add_option("--additional-help", action="help", default=True)
with pytest.raises(
FalyxOptionError, match="default value cannot be set for action 'tldr'"
):
parser.add_option("--more-tldr", action="tldr", default=True)
def test_add_option_rejects_choices_for_boolean_option(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="choices cannot be specified"):
parser.add_option("--foo", action="store_true", choices=["yes"])
def test_add_option_rejects_default_outside_choices(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="not in allowed choices"):
parser.add_option(
"--region",
default="eu-central",
choices=["us-east", "us-west"],
)
def test_add_option_rejects_invalid_default_type(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="cannot be coerced to int"):
parser.add_option("--retries", type=int, default="not-an-int")
def test_add_option_rejects_invalid_choice_type(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="invalid choice"):
parser.add_option("--retries", type=int, choices=["1", "bad"])
def test_add_option_rejects_non_list_suggestions(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="suggestions must be a list or None"):
parser.add_option("--profile", suggestions=("dev", "prod"))
def test_add_option_rejects_non_string_suggestions(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="suggestions must be a list of strings"):
parser.add_option("--profile", suggestions=["dev", 1])
def test_add_option_rejects_non_string_flags(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="invalid flag '123': must be a string"):
parser.add_option("--region", 123)
def test_add_option_rejects_flags_with_invalid_prefix(parser: FalyxParser) -> None:
with pytest.raises(
FalyxOptionError, match="invalid flag 'region': must start with '-'"
):
parser.add_option("region")
def test_add_option_rejects_long_flag_with_insufficient_length(
parser: FalyxParser,
) -> None:
with pytest.raises(
FalyxOptionError, match="long flags must have at least one character after '--'"
):
parser.add_option("--", dest="invalid")
def test_add_option_rejects_speacial_characters_in_dest(parser: FalyxParser) -> None:
with pytest.raises(
FalyxOptionError, match="invalid dest 'bad-dest': must be a valid identifier"
):
parser.add_option("--bad-dest", dest="bad-dest")
with pytest.raises(
FalyxOptionError, match=r"invalid dest 'bad\*dest': must be a valid identifier"
):
parser.add_option("--bad-dest", dest="bad*dest")
with pytest.raises(
FalyxOptionError, match="invalid dest '1bad-dest': must be a valid identifier"
):
parser.add_option("--bad-dest", dest="1bad-dest")
def test_add_option_rejects_dest_starting_with_digit(parser: FalyxParser) -> None:
with pytest.raises(
FalyxOptionError, match="invalid dest '1bad': cannot start with a digit"
):
parser.add_option("--1bad", dest="1bad")
def test_add_option_rejects_special_characters_in_flags(parser: FalyxParser) -> None:
with pytest.raises(
FalyxOptionError,
match=r"invalid flag '--bad\*flag': must only contain letters, digits, underscores, or hyphens",
):
parser.add_option("--bad*flag", dest="bad_flag")
def test_add_option_rejects_short_flag_with_multiple_characters(
parser: FalyxParser,
) -> None:
with pytest.raises(
FalyxOptionError,
match="invalid flag '-ab': short flags must be a single character",
):
parser.add_option("-ab")
def test_add_option_rejects_bad_flags(parser: FalyxParser) -> None:
with pytest.raises(
FalyxOptionError,
match="--region1@': must only contain letters, digits, underscores, or hyphens",
):
parser.add_option("--region1@")
with pytest.raises(
FalyxOptionError, match="invalid dest '42region': cannot start with a digit"
):
parser.add_option("--42region")
def test_register_option_rejects_duplicate_flag(parser: FalyxParser) -> None:
parser.add_option("--region", "-r")
parser.add_option("--profile", "-p")
option1 = Option(flags=("--region", "-r"), dest="region")
option2 = Option(flags=("--profile", "-p"), dest="profile")
with pytest.raises(FalyxOptionError, match="already used"):
parser._register_option(option1)
with pytest.raises(FalyxOptionError, match="already used"):
parser._register_option(option2)
def test_parse_args_with_no_args_returns_defaults(parser: FalyxParser) -> None:
result = parser.parse_args([])
assert result.mode is FalyxMode.COMMAND
assert result.raw_argv == []
assert result.remaining_argv == []
assert result.current_head == ""
assert result.help is False
assert result.tldr is False
assert result.namespace_defaults["help"] is False
assert result.root_defaults["verbose"] is False
assert result.root_defaults["debug_hooks"] is False
assert result.root_defaults["never_prompt"] is False
def test_parse_args_splits_root_and_namespace_options(parser: FalyxParser) -> None:
parser.add_option("--profile", default="dev")
result = parser.parse_args(["--verbose", "--profile", "prod", "deploy"])
assert result.root_options == {"verbose": True}
assert result.namespace_options == {"profile": "prod"}
assert result.remaining_argv == ["deploy"]
assert result.current_head == "deploy"
@pytest.mark.parametrize("help_flag", ["-h", "--help"])
def test_parse_args_help_flag_sets_help_mode(
parser: FalyxParser,
help_flag: str,
) -> None:
result = parser.parse_args([help_flag])
assert result.mode is FalyxMode.HELP
assert result.help is True
assert result.namespace_options["help"] is True
assert result.remaining_argv == []
def test_parse_args_tldr_flag_sets_help_mode_after_tldr_registered(
parser: FalyxParser,
) -> None:
parser.add_tldr_example(
entry_key="D",
usage="--region us-east",
description="Deploy to us-east",
)
result = parser.parse_args(["--tldr"])
assert result.mode is FalyxMode.HELP
assert result.tldr is True
assert result.namespace_options["tldr"] is True
def test_parse_args_unknown_leading_option_raises(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="unknown option '--wat'"):
parser.parse_args(["--wat"])
def test_parse_args_stops_at_first_non_option_boundary(parser: FalyxParser) -> None:
result = parser.parse_args(["deploy", "--verbose", "--never-prompt"])
assert result.root_options == {}
assert result.namespace_options == {}
assert result.remaining_argv == ["deploy", "--verbose", "--never-prompt"]
assert result.current_head == "deploy"
def test_parse_args_allows_unknown_options_after_route_boundary(
parser: FalyxParser,
) -> None:
result = parser.parse_args(["deploy", "--command-local-option"])
assert result.remaining_argv == ["deploy", "--command-local-option"]
def test_parse_args_store_true_and_store_false(parser: FalyxParser) -> None:
parser.add_option("--json", action="store_true")
parser.add_option("--color", action="store_false")
result = parser.parse_args(["--json", "--color"])
assert result.namespace_defaults["json"] is False
assert result.namespace_defaults["color"] is True
assert result.namespace_options["json"] is True
assert result.namespace_options["color"] is False
def test_parse_args_count_option(parser: FalyxParser) -> None:
parser.add_option("-q", "--quiet", action="count")
result = parser.parse_args(["-q", "-q", "--quiet"])
assert result.namespace_defaults["quiet"] == 0
assert result.namespace_options["quiet"] == 3
def test_parse_args_posix_bundles_boolean_and_count_options(
parser: FalyxParser,
) -> None:
parser.add_option("-q", "--quiet", action="count")
result = parser.parse_args(["-vdnq", "deploy"])
assert result.root_options == {
"verbose": True,
"debug_hooks": True,
"never_prompt": True,
}
assert result.namespace_options == {"quiet": 1}
assert result.remaining_argv == ["deploy"]
def test_parse_args_posix_bundle_can_end_with_store_option(
parser: FalyxParser,
) -> None:
parser.add_option("-q", "--quiet", action="count")
parser.add_option("-r", "--region")
result = parser.parse_args(["-qr", "us-east", "deploy"])
assert result.namespace_options == {
"quiet": 1,
"region": "us-east",
}
assert result.remaining_argv == ["deploy"]
def test_parse_args_does_not_expand_invalid_posix_bundle(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="unknown option '-vz'"):
parser.parse_args(["-vz"])
def test_parse_args_store_option_requires_value(parser: FalyxParser) -> None:
parser.add_option("--region")
with pytest.raises(FalyxOptionError, match="expected a value"):
parser.parse_args(["--region"])
def test_parse_args_store_option_coerces_value(parser: FalyxParser) -> None:
parser.add_option("--retries", type=int)
result = parser.parse_args(["--retries", "3"])
assert result.namespace_options["retries"] == 3
def test_parse_args_store_option_rejects_invalid_value(parser: FalyxParser) -> None:
parser.add_option("--retries", type=int)
with pytest.raises(FalyxOptionError, match="invalid value for '--retries'"):
parser.parse_args(["--retries", "abc"])
def test_parse_args_store_option_rejects_value_outside_choices(
parser: FalyxParser,
) -> None:
parser.add_option("--region", choices=["us-east", "us-west"])
with pytest.raises(FalyxOptionError, match="expected one of"):
parser.parse_args(["--region", "eu-central"])
def test_suggest_next_returns_no_suggestions_for_empty_args(
parser: FalyxParser,
) -> None:
suggestions, expecting_value = parser.suggest_next([], cursor_at_end_of_token=False)
assert suggestions == []
assert expecting_value is False
def test_suggest_next_suggests_matching_option_flags(parser: FalyxParser) -> None:
parser.add_option("--region", "-r")
suggestions, expecting_value = parser.suggest_next(
["--r"],
cursor_at_end_of_token=False,
)
assert suggestions == ["--region"]
assert expecting_value is False
def test_suggest_next_suggests_all_remaining_flags_at_token_boundary(
parser: FalyxParser,
) -> None:
parser.add_option("--region", "-r")
suggestions, expecting_value = parser.suggest_next(
["--"],
cursor_at_end_of_token=False,
)
assert "--help" in suggestions
assert "--verbose" in suggestions
assert "--debug-hooks" in suggestions
assert "--never-prompt" in suggestions
assert "--region" in suggestions
assert expecting_value is False
def test_suggest_next_suggests_choice_values_after_store_option(
parser: FalyxParser,
) -> None:
parser.add_option("--region", choices=["us-east", "us-west"])
suggestions, expecting_value = parser.suggest_next(
["--region"],
cursor_at_end_of_token=True,
)
assert suggestions == ["us-east", "us-west"]
assert expecting_value is True
def test_suggest_next_filters_choice_values_by_prefix(parser: FalyxParser) -> None:
parser.add_option("--region", choices=["us-east", "us-west", "eu-central"])
suggestions, expecting_value = parser.suggest_next(
["--region", "us-e"],
cursor_at_end_of_token=False,
)
assert suggestions == ["us-east"]
assert expecting_value is True
def test_suggest_next_uses_custom_value_suggestions(parser: FalyxParser) -> None:
parser.add_option("--profile", suggestions=["dev", "prod", "staging"])
suggestions, expecting_value = parser.suggest_next(
["--profile", "pr"],
cursor_at_end_of_token=False,
)
assert suggestions == ["prod"]
assert expecting_value is True
def test_suggest_next_excludes_consumed_options(parser: FalyxParser) -> None:
parser.add_option("--region", choices=["us-east", "us-west"])
parser.parse_args(["--region", "us-east"])
suggestions, expecting_value = parser.suggest_next(
["-"],
cursor_at_end_of_token=False,
)
assert "--region" not in suggestions
assert "-r" not in suggestions
assert expecting_value is False
def test_add_tldr_example_registers_example_and_tldr_option(
parser: FalyxParser,
) -> None:
parser.add_tldr_example(
entry_key="D",
usage="--region us-east",
description="Deploy to us-east",
)
assert parser.tldr_option is not None
assert "--tldr" in parser._options_by_dest
assert parser._tldr_examples == [
FalyxTLDRExample(
entry_key="D",
usage="--region us-east",
description="Deploy to us-east",
)
]
def test_add_tldr_example_rejects_unknown_entry(parser: FalyxParser) -> None:
with pytest.raises(EntryNotFoundError) as error:
parser.add_tldr_example(
entry_key="depoy",
usage="",
description="Typo example",
)
assert error.value.unknown_name == "depoy"
assert error.value.suggestions == ["DEPLOY"]
def test_add_tldr_examples_accepts_dataclass_instances(
parser: FalyxParser,
) -> None:
example = FalyxTLDRExample(
entry_key="deploy",
usage="--region us-east",
description="Deploy to us-east",
)
parser.add_tldr_examples([example])
assert parser.tldr_option is not None
assert parser._tldr_examples == [example]
def test_add_tldr_examples_accepts_three_tuple_examples(
parser: FalyxParser,
) -> None:
parser.add_tldr_examples(
[
("deploy", "--region us-east", "Deploy to us-east"),
]
)
assert parser.tldr_option is not None
assert parser._tldr_examples == [
FalyxTLDRExample(
entry_key="deploy",
usage="--region us-east",
description="Deploy to us-east",
)
]
def test_add_tldr_examples_rejects_invalid_tuple_shape(
parser: FalyxParser,
) -> None:
with pytest.raises(FalyxOptionError, match="invalid TLDR example format"):
parser.add_tldr_examples([("deploy", "missing description")])
def test_add_tldr_examples_rejects_unknown_entry(parser: FalyxParser) -> None:
with pytest.raises(EntryNotFoundError) as error:
parser.add_tldr_examples(
[
("depoy", "--region us-east", "Typo example"),
]
)
with pytest.raises(EntryNotFoundError) as error:
parser.add_tldr_examples(
[
FalyxTLDRExample(
entry_key="depoy",
usage="--region us-east",
description="Typo example",
)
]
)
assert error.value.unknown_name == "depoy"
assert error.value.suggestions == ["DEPLOY"]
def test_store_bool_optional_registers_positive_and_negative_flags(
parser: FalyxParser,
) -> None:
parser.add_option("--cache", action="store_bool_optional")
assert "--cache" in parser._options_by_dest
assert "--no-cache" in parser._options_by_dest
result = parser.parse_args([])
assert result.namespace_defaults["cache"] is None
result = parser.parse_args(["--cache"])
assert result.namespace_options["cache"] is True
result = parser.parse_args(["--no-cache"])
assert result.namespace_options["cache"] is False
@pytest.mark.parametrize(
("flag", "expected"),
[
("--cache", True),
("--no-cache", False),
],
)
def test_parse_args_store_bool_optional_intended_behavior(
parser: FalyxParser,
flag: str,
expected: bool,
) -> None:
parser.add_option("--cache", action="store_bool_optional")
result = parser.parse_args([flag])
assert result.namespace_options["cache"] is expected
def test_parse_args_store_bool_optional_rejects_multiple_flags(
parser: FalyxParser,
) -> None:
with pytest.raises(
FalyxOptionError, match="store_bool_optional action can only have a single flag"
):
parser.add_option("--cache", "-c", action="store_bool_optional")
def test_parse_args_store_bool_optional_rejects_short_flags(parser: FalyxParser) -> None:
with pytest.raises(
FalyxOptionError, match="store_bool_optional action must use a long flag"
):
parser.add_option("-c", action="store_bool_optional")
def test_parse_args_long_root_flags(parser: FalyxParser) -> None:
result = parser.parse_args(["--verbose", "--debug-hooks", "--never-prompt", "deploy"])
assert result.root_options == {
"verbose": True,
"debug_hooks": True,
"never_prompt": True,
}
assert result.namespace_options == {}
assert result.remaining_argv == ["deploy"]
@pytest.mark.parametrize("help_flag", ["-h", "--help"])
def test_parse_args_forwards_help_after_route_boundary(
parser: FalyxParser,
help_flag: str,
) -> None:
result = parser.parse_args(["deploy", help_flag])
assert result.mode is FalyxMode.COMMAND
assert result.help is False
assert result.namespace_options == {}
assert result.remaining_argv == ["deploy", help_flag]
def test_parse_args_short_tldr_flag_sets_help_mode_after_tldr_registered(
parser: FalyxParser,
) -> None:
parser.add_tldr_example(
entry_key="D",
usage="--region us-east",
description="Deploy to us-east",
)
result = parser.parse_args(["-T"])
assert result.mode is FalyxMode.HELP
assert result.tldr is True
assert result.namespace_options["tldr"] is True
def test_add_tldr_examples_registers_tldr_option_only_once(
parser: FalyxParser,
) -> None:
parser.add_tldr_example(
entry_key="deploy",
usage="--region us-east",
description="Deploy to us-east",
)
parser.add_tldr_example(
entry_key="deploy",
usage="--region us-west",
description="Deploy to us-west",
)
tldr_options = [option for option in parser.get_options() if option.dest == "tldr"]
assert len(tldr_options) == 1
assert parser.get_flags().count("--tldr") == 1
assert len(parser._tldr_examples) == 2
def test_parse_args_resets_consumed_option_state_between_parses(
parser: FalyxParser,
) -> None:
parser.add_option("--region", "-r", choices=["us-east", "us-west"])
parser.parse_args(["--region", "us-east"])
suggestions, _ = parser.suggest_next(["-"], cursor_at_end_of_token=False)
assert "--region" not in suggestions
parser.parse_args([])
suggestions, expecting_value = parser.suggest_next(
["--r"],
cursor_at_end_of_token=False,
)
assert suggestions == ["--region"]
assert expecting_value is False
def test_disabled_reserved_root_options_are_omitted_from_defaults() -> None:
parser = FalyxParser(
Falyx(
disable_verbose_option=True,
disable_debug_hooks_option=True,
disable_never_prompt_option=True,
)
)
result = parser.parse_args([])
assert result.root_defaults == {}
assert result.namespace_defaults["help"] is False
def test_parse_args_typed_choices_are_compared_after_coercion(
parser: FalyxParser,
) -> None:
parser.add_option("--retries", type=int, choices=["1", "2"])
result = parser.parse_args(["--retries", "1"])
assert result.namespace_options["retries"] == 1
def test_add_option_rejects_dict_choices(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="choices cannot be a dict"):
parser.add_option("--region", choices={"east": "us-east"})
def test_add_option_rejects_non_iterable_choices(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="choices must be iterable"):
parser.add_option("--region", choices=1)
@pytest.mark.parametrize("flag", ["--verbose", "--debug-hooks", "--never-prompt"])
def test_suggest_next_does_not_expect_value_for_root_boolean_flags(
parser: FalyxParser,
flag: str,
) -> None:
suggestions, expecting_value = parser.suggest_next(
[flag],
cursor_at_end_of_token=True,
)
assert suggestions == []
assert expecting_value is False
def test_add_option_normalizes_typed_choices(parser: FalyxParser) -> None:
option = parser.add_option("--retries", type=int, choices=["1", "2"])
assert option.choices == [1, 2]
def test_parse_args_accepts_value_matching_normalized_typed_choice(
parser: FalyxParser,
) -> None:
parser.add_option("--retries", type=int, choices=["1", "2"])
result = parser.parse_args(["--retries", "1"])
assert result.namespace_options["retries"] == 1
def test_add_option_normalizes_typed_default_before_choice_check(
parser: FalyxParser,
) -> None:
parser.add_option("--retries", type=int, choices=["1", "2"], default="1")
result = parser.parse_args([])
assert result.namespace_defaults["retries"] == 1
def test_add_option_returns_registered_option(parser: FalyxParser) -> None:
option = parser.add_option(
"--retries",
type=int,
default="1",
choices=["1", "2"],
)
assert isinstance(option, Option)
assert option.dest == "retries"
assert option.default == 1
assert option.choices == [1, 2]
assert option in parser.get_options()
def test_add_option_store_bool_optional_returns_primary_option(
parser: FalyxParser,
) -> None:
option = parser.add_option("--cache", action="store_bool_optional")
assert option.dest == "cache"
assert option.flags == ("--cache",)
assert "--cache" in parser._options_by_dest
assert "--no-cache" in parser._options_by_dest

226
tests/test_hook_manager.py Normal file
View File

@@ -0,0 +1,226 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
import pytest
from falyx.hook_manager import HookManager, HookType
def make_context(*, name: str = "DemoAction", exception: Exception | None = None) -> Any:
return SimpleNamespace(name=name, exception=exception, events=[])
def test_hook_type_choices_aliases_and_string_representation() -> None:
assert HookType.choices() == [
HookType.BEFORE,
HookType.ON_SUCCESS,
HookType.ON_ERROR,
HookType.AFTER,
HookType.ON_TEARDOWN,
]
assert HookType(" before ") is HookType.BEFORE
assert HookType("success") is HookType.ON_SUCCESS
assert HookType(" ERROR ") is HookType.ON_ERROR
assert HookType("teardown") is HookType.ON_TEARDOWN
assert str(HookType.AFTER) == "after"
@pytest.mark.parametrize("bad_value", [7, object()])
def test_hook_type_rejects_non_string_missing_values(bad_value: object) -> None:
with pytest.raises(ValueError, match="Invalid HookType"):
HookType(bad_value)
def test_hook_type_rejects_unknown_string_with_valid_choices() -> None:
with pytest.raises(ValueError) as exc_info:
HookType("not-a-hook")
message = str(exc_info.value)
assert "Invalid HookType: 'not-a-hook'" in message
assert "before" in message
assert "on_success" in message
assert "on_error" in message
assert "after" in message
assert "on_teardown" in message
def test_manager_initializes_all_hook_buckets_and_registers_aliases() -> None:
manager = HookManager()
assert set(manager._hooks) == set(HookType)
assert all(hooks == [] for hooks in manager._hooks.values())
def before_hook(context: Any) -> None:
context.events.append("before")
def success_hook(context: Any) -> None:
context.events.append("success")
manager.register(HookType.BEFORE, before_hook)
manager.register("success", success_hook)
assert manager._hooks[HookType.BEFORE] == [before_hook]
assert manager._hooks[HookType.ON_SUCCESS] == [success_hook]
def test_register_rejects_invalid_hook_type() -> None:
manager = HookManager()
def hook(context: Any) -> None:
context.events.append("never-called")
with pytest.raises(ValueError, match="Invalid HookType"):
manager.register("missing-phase", hook)
@pytest.mark.asyncio
async def test_trigger_runs_sync_and_async_hooks_in_registration_order() -> None:
manager = HookManager()
context = make_context()
def sync_first(ctx: Any) -> None:
ctx.events.append("sync-first")
async def async_second(ctx: Any) -> None:
ctx.events.append("async-second")
def sync_third(ctx: Any) -> None:
ctx.events.append("sync-third")
manager.register("before", sync_first)
manager.register(HookType.BEFORE, async_second)
manager.register("before", sync_third)
await manager.trigger(HookType.BEFORE, context)
assert context.events == ["sync-first", "async-second", "sync-third"]
@pytest.mark.asyncio
async def test_trigger_rejects_unsupported_runtime_hook_type() -> None:
manager = HookManager()
with pytest.raises(ValueError, match="Unsupported hook type"):
await manager.trigger("not-a-hook", make_context()) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_trigger_logs_and_continues_after_non_error_hook_failure() -> None:
manager = HookManager()
context = make_context()
def failing_hook(ctx: Any) -> None:
ctx.events.append("failing")
raise RuntimeError("hook exploded")
def surviving_hook(ctx: Any) -> None:
ctx.events.append("surviving")
manager.register(HookType.BEFORE, failing_hook)
manager.register(HookType.BEFORE, surviving_hook)
await manager.trigger(HookType.BEFORE, context)
assert context.events == ["failing", "surviving"]
@pytest.mark.asyncio
async def test_trigger_on_error_hook_failure_reraises_original_context_exception() -> (
None
):
manager = HookManager()
original_error = ValueError("original failure")
context = make_context(exception=original_error)
def failing_error_hook(ctx: Any) -> None:
ctx.events.append("error-hook")
raise RuntimeError("error hook failed")
manager.register("error", failing_error_hook)
with pytest.raises(ValueError) as exc_info:
await manager.trigger(HookType.ON_ERROR, context)
assert exc_info.value is original_error
assert isinstance(exc_info.value.__cause__, RuntimeError)
assert str(exc_info.value.__cause__) == "error hook failed"
assert context.events == ["error-hook"]
@pytest.mark.asyncio
async def test_trigger_on_error_requires_context_exception_when_hook_fails() -> None:
manager = HookManager()
context = make_context(exception=None)
def failing_error_hook(ctx: Any) -> None:
raise RuntimeError("error hook failed")
manager.register(HookType.ON_ERROR, failing_error_hook)
with pytest.raises(AssertionError, match="Context exception should be set"):
await manager.trigger(HookType.ON_ERROR, context)
def test_clear_removes_one_hook_bucket_or_all_buckets() -> None:
manager = HookManager()
def before_hook(context: Any) -> None:
context.events.append("before")
def after_hook(context: Any) -> None:
context.events.append("after")
manager.register("before", before_hook)
manager.register("after", after_hook)
manager.clear(HookType.BEFORE)
assert manager._hooks[HookType.BEFORE] == []
assert manager._hooks[HookType.AFTER] == [after_hook]
manager.clear()
assert all(hooks == [] for hooks in manager._hooks.values())
def test_string_representation_lists_registered_hook_names_and_empty_buckets() -> None:
manager = HookManager()
def before_hook(context: Any) -> None:
context.events.append("before")
manager.register("before", before_hook)
text = str(manager)
assert text.startswith("<HookManager>")
assert "before: before_hook" in text
assert "on_success: —" in text
assert "on_error: —" in text
assert "after: —" in text
assert "on_teardown: —" in text
def test_copy_copies_hook_lists_without_sharing_list_objects() -> None:
manager = HookManager()
def first_hook(context: Any) -> None:
context.events.append("first")
def second_hook(context: Any) -> None:
context.events.append("second")
manager.register("teardown", first_hook)
clone = manager.copy()
assert clone is not manager
assert clone._hooks[HookType.ON_TEARDOWN] == [first_hook]
assert clone._hooks[HookType.ON_TEARDOWN] is not manager._hooks[HookType.ON_TEARDOWN]
clone.register("teardown", second_hook)
assert manager._hooks[HookType.ON_TEARDOWN] == [first_hook]
assert clone._hooks[HookType.ON_TEARDOWN] == [first_hook, second_hook]

View File

@@ -5,7 +5,7 @@ from falyx.action import Action
from falyx.console import console as falyx_console from falyx.console import console as falyx_console
from falyx.exceptions import CommandArgumentError, NotAFalyxError from falyx.exceptions import CommandArgumentError, NotAFalyxError
from falyx.options_manager import OptionsManager from falyx.options_manager import OptionsManager
from falyx.parser import ArgumentAction, CommandArgumentParser from falyx.parser import Argument, ArgumentAction, CommandArgumentParser
from falyx.signals import HelpSignal 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" CommandArgumentError, match="lazy_resolver must be a boolean, got int"
): ):
parser.add_argument("--valid", lazy_resolver=123) parser.add_argument("--valid", lazy_resolver=123)
def test_add_argument_returns_registered_argument() -> None:
parser = CommandArgumentParser()
arg = parser.add_argument(
"--retries",
type=int,
default="1",
choices=["1", "2"],
)
assert isinstance(arg, Argument)
assert arg.dest == "retries"
assert arg.default == 1
assert arg.choices == [1, 2]
assert parser.get_argument("retries") is arg

View File

@@ -0,0 +1,373 @@
from falyx.console import console
from falyx.execution_option import ExecutionOption
from falyx.options_manager import OptionsManager
from falyx.parser import CommandArgumentParser
from falyx.parser.parser_types import TLDRExample
def build_parser() -> CommandArgumentParser:
parser = CommandArgumentParser(
command_key="D",
command_description="Deploy",
help_text="Deploy something.",
help_epilog="More help text.",
aliases=["deploy"],
program="source",
options_manager=OptionsManager(),
)
parser.add_argument("--region", choices=["us-east", "us-west"], default="us-east")
parser.add_argument("target")
group = parser.add_argument_group("auth", description="Authentication options")
group.add_argument("--profile", suggestions=["dev", "prod"])
mutex = parser.add_mutually_exclusive_group(
"mode",
required=False,
description="Execution mode",
)
mutex.add_argument("--dry-run", action="store_true")
mutex.add_argument("--apply", action="store_true")
parser.add_tldr_examples(
[
("target-1 --region us-east", "Deploy target-1 to us-east."),
("target-2 --dry-run", "Preview target-2 without executing."),
]
)
parser.enable_execution_options(
frozenset(
{
ExecutionOption.SUMMARY,
ExecutionOption.RETRY,
ExecutionOption.CONFIRM,
}
)
)
return parser
def build_parser_with_tldr_examples() -> CommandArgumentParser:
parser = build_parser()
parser.add_tldr_examples(
[
("target-3 --profile dev", "Deploy target-3 using dev profile."),
]
)
return parser
def build_parser_with_groups() -> CommandArgumentParser:
parser = build_parser()
group = parser.add_argument_group("output", description="Output options")
group.add_argument("--json", action="store_true")
return parser
def build_parser_with_execution_options() -> CommandArgumentParser:
parser = build_parser()
parser.enable_execution_options(
frozenset(
{
ExecutionOption.SUMMARY,
ExecutionOption.RETRY,
ExecutionOption.CONFIRM,
}
)
)
return parser
def test_clone_with_overrides_preserves_core_metadata():
original = build_parser()
new_options = OptionsManager()
cloned = original.clone_with_overrides(
command_key="X",
command_description="Execute",
help_text="Execute something else.",
help_epilog="Different epilog.",
aliases=["execute"],
program="target",
options_manager=new_options,
)
assert cloned is not original
assert cloned.command_key == "X"
assert cloned.command_description == "Execute"
assert cloned.help_text == "Execute something else."
assert cloned.help_epilog == "Different epilog."
assert cloned.aliases == ["execute"]
assert cloned.program == "target"
assert cloned.options_manager is new_options
def test_clone_with_overrides_keeps_execution_options_enabled_without_double_registration():
original = build_parser()
cloned = original.clone_with_overrides()
summary = cloned.get_argument("summary")
retries = cloned.get_argument("retries")
retry_delay = cloned.get_argument("retry_delay")
retry_backoff = cloned.get_argument("retry_backoff")
force_confirm = cloned.get_argument("force_confirm")
skip_confirm = cloned.get_argument("skip_confirm")
assert summary is not None
assert retries is not None
assert retry_delay is not None
assert retry_backoff is not None
assert force_confirm is not None
assert skip_confirm is not None
# Re-enabling on the clone should be idempotent, not duplicate flags/dests.
cloned.enable_execution_options(
frozenset(
{
ExecutionOption.SUMMARY,
ExecutionOption.RETRY,
ExecutionOption.CONFIRM,
}
)
)
assert len([arg for arg in cloned._arguments if arg.dest == "summary"]) == 1
assert len([arg for arg in cloned._arguments if arg.dest == "retries"]) == 1
assert len([arg for arg in cloned._arguments if arg.dest == "retry_delay"]) == 1
assert len([arg for arg in cloned._arguments if arg.dest == "retry_backoff"]) == 1
assert len([arg for arg in cloned._arguments if arg.dest == "force_confirm"]) == 1
assert len([arg for arg in cloned._arguments if arg.dest == "skip_confirm"]) == 1
def test_clone_with_overrides_preserves_groups_and_mutex_groups():
original = build_parser()
cloned = original.clone_with_overrides()
assert "auth" in cloned._argument_groups
assert "mode" in cloned._mutex_groups
assert cloned._arg_group_by_dest["profile"] == "auth"
assert cloned._mutex_group_by_dest["dry_run"] == "mode"
assert cloned._mutex_group_by_dest["apply"] == "mode"
assert cloned.get_argument("profile") is not None
assert cloned.get_argument("dry_run") is not None
assert cloned.get_argument("apply") is not None
def test_clone_with_overrides_preserves_tldr_examples_and_help_flags():
original = build_parser()
cloned = original.clone_with_overrides()
assert cloned.help_text == original.help_text
assert cloned.help_epilog == original.help_epilog
assert cloned.get_argument("help") is not None
assert cloned.get_argument("tldr") is not None
assert cloned._tldr_examples == original._tldr_examples
assert cloned._tldr_examples is not original._tldr_examples
def test_clone_with_overrides_does_not_share_argument_registries_with_original():
original = build_parser()
cloned = original.clone_with_overrides()
assert cloned._arguments is not original._arguments
assert cloned._positional is not original._positional
assert cloned._keyword is not original._keyword
assert cloned._keyword_list is not original._keyword_list
assert cloned._flag_map is not original._flag_map
assert cloned._dest_set is not original._dest_set
assert cloned._execution_dests is not original._execution_dests
cloned.add_argument("--new-flag", default="x")
assert cloned.get_argument("new_flag") is not None
assert original.get_argument("new_flag") is None
def test_clone_with_overrides_does_not_share_group_registries_with_original():
original = build_parser()
cloned = original.clone_with_overrides()
assert cloned._argument_groups is not original._argument_groups
assert cloned._mutex_groups is not original._mutex_groups
assert cloned._arg_group_by_dest is not original._arg_group_by_dest
assert cloned._mutex_group_by_dest is not original._mutex_group_by_dest
cloned_group = cloned.add_argument_group("output", description="Output options")
cloned_group.add_argument("--json", action="store_true")
assert "output" in cloned._argument_groups
assert "output" not in original._argument_groups
assert cloned.get_argument("json") is not None
assert original.get_argument("json") is None
def test_clone_with_overrides_reuses_no_mutable_group_objects():
original = build_parser()
cloned = original.clone_with_overrides()
# These should ideally be distinct objects too, not just distinct dicts.
assert cloned._argument_groups["auth"] is not original._argument_groups["auth"]
assert cloned._mutex_groups["mode"] is not original._mutex_groups["mode"]
def test_clone_with_overrides_reuses_no_mutable_argument_objects():
original = build_parser()
cloned = original.clone_with_overrides()
# Strict contract: cloned parser should not share Argument instances either.
assert cloned.get_argument("region") is not original.get_argument("region")
assert cloned.get_argument("target") is not original.get_argument("target")
assert cloned.get_argument("profile") is not original.get_argument("profile")
def test_clone_with_overrides_uses_new_options_manager():
original = build_parser()
new_options = OptionsManager()
cloned = original.clone_with_overrides(options_manager=new_options)
assert cloned.options_manager is new_options
assert original.options_manager is not new_options
def test_clone_with_overrides_has_single_help_and_single_tldr_argument():
parser = build_parser_with_tldr_examples()
cloned = parser.clone_with_overrides()
assert len([arg for arg in cloned._arguments if arg.dest == "help"]) == 1
assert len([arg for arg in cloned._arguments if arg.dest == "tldr"]) == 1
assert cloned.get_argument("help") is not None
assert cloned.get_argument("tldr") is not None
def test_clone_with_overrides_copies_tldr_examples():
parser = build_parser_with_tldr_examples()
cloned = parser.clone_with_overrides()
assert cloned._tldr_examples == parser._tldr_examples
assert cloned._tldr_examples is not parser._tldr_examples
assert all(c is not o for c, o in zip(cloned._tldr_examples, parser._tldr_examples))
def test_clone_with_overrides_copies_explicit_tldr_examples():
parser = build_parser()
examples = [TLDRExample("foo", "bar")]
cloned = parser.clone_with_overrides(tldr_examples=examples)
assert cloned._tldr_examples == examples
assert cloned._tldr_examples is not examples
def test_clone_with_overrides_does_not_share_aliases_list():
parser = build_parser()
cloned = parser.clone_with_overrides()
assert cloned.aliases == parser.aliases
assert cloned.aliases is not parser.aliases
cloned.aliases.append("new-alias")
assert "new-alias" not in parser.aliases
def test_clone_with_overrides_rebuilds_group_membership_without_duplicates():
parser = build_parser_with_groups()
cloned = parser.clone_with_overrides()
assert cloned._argument_groups["auth"].dests == {"profile"}
assert set(cloned._mutex_groups["mode"].dests) == {"dry_run", "apply"}
assert len(cloned._mutex_groups["mode"].dests) == 2
def test_clone_with_overrides_does_not_share_group_objects():
parser = build_parser_with_groups()
cloned = parser.clone_with_overrides()
assert cloned._argument_groups is not parser._argument_groups
assert cloned._mutex_groups is not parser._mutex_groups
assert cloned._argument_groups["auth"] is not parser._argument_groups["auth"]
assert cloned._mutex_groups["mode"] is not parser._mutex_groups["mode"]
def test_clone_with_overrides_does_not_share_argument_objects():
parser = build_parser()
cloned = parser.clone_with_overrides()
for original_arg in parser._arguments:
cloned_arg = cloned.get_argument(original_arg.dest)
console.print(original_arg)
console.print(cloned_arg)
assert cloned_arg is not None
assert cloned_arg is not original_arg
assert cloned_arg == original_arg
def test_clone_with_overrides_internal_registries_point_to_cloned_arguments():
parser = build_parser()
cloned = parser.clone_with_overrides()
for arg in cloned._arguments:
for flag in arg.flags:
assert cloned._flag_map[flag] is arg
if not arg.positional:
assert cloned._keyword[flag] is arg
if arg.positional:
assert cloned._positional[arg.dest] is arg
else:
assert arg in cloned._keyword_list
def test_clone_with_overrides_preserves_execution_option_state_without_duplication():
parser = build_parser_with_execution_options()
cloned = parser.clone_with_overrides()
assert cloned._summary_enabled is True
assert cloned._retries_enabled is True
assert cloned._confirm_enabled is True
assert cloned._execution_dests == parser._execution_dests
assert cloned._execution_dests is not parser._execution_dests
cloned.enable_execution_options(
frozenset(
{
ExecutionOption.SUMMARY,
ExecutionOption.RETRY,
ExecutionOption.CONFIRM,
}
)
)
assert len([arg for arg in cloned._arguments if arg.dest == "summary"]) == 1
assert len([arg for arg in cloned._arguments if arg.dest == "retries"]) == 1
assert len([arg for arg in cloned._arguments if arg.dest == "retry_delay"]) == 1
assert len([arg for arg in cloned._arguments if arg.dest == "retry_backoff"]) == 1
assert len([arg for arg in cloned._arguments if arg.dest == "force_confirm"]) == 1
assert len([arg for arg in cloned._arguments if arg.dest == "skip_confirm"]) == 1
def test_clone_with_overrides_preserves_runner_and_help_mode_flags():
parser = build_parser()
parser.is_runner_mode = True
parser.mark_as_help_command()
cloned = parser.clone_with_overrides()
assert cloned.is_runner_mode is True
assert cloned._is_help_command is True
def test_clone_with_overrides_mutating_clone_does_not_mutate_original():
parser = build_parser()
cloned = parser.clone_with_overrides()
cloned.add_argument("--new-flag", default="x")
assert cloned.get_argument("new_flag") is not None
assert parser.get_argument("new_flag") is None

View File

@@ -0,0 +1,459 @@
from __future__ import annotations
from io import StringIO
from pathlib import Path
import pytest
from rich.console import Console
from falyx.action.action import Action
from falyx.exceptions import CommandArgumentError, InvalidValueError
from falyx.execution_option import ExecutionOption
from falyx.parser.command_argument_parser import CommandArgumentParser
from falyx.signals import HelpSignal
@pytest.fixture
def parser() -> CommandArgumentParser:
return CommandArgumentParser(
command_key="D",
command_description="Deploy service",
help_text="Deploy a service.",
help_epilog="Deployment epilog.",
aliases=["deploy"],
program="flx",
)
def capture_console(parser: CommandArgumentParser) -> StringIO:
stream = StringIO()
parser.console = Console(
file=stream,
force_terminal=False,
color_system=None,
width=120,
)
return stream
def test_add_argument_rejects_suggestions_with_non_string_members(
parser: CommandArgumentParser,
) -> None:
with pytest.raises(
CommandArgumentError, match="suggestions must be a list of strings"
):
parser.add_argument("--region", suggestions=["dev", 1])
@pytest.mark.asyncio
async def test_parse_accepts_multi_value_choice_list_when_all_values_are_valid(
parser: CommandArgumentParser,
) -> None:
parser.add_argument(
"--ports",
type=int,
nargs="+",
choices=["80", 443],
default=[],
)
result = await parser.parse_args(["--ports", "80", "443"])
assert result["ports"] == [80, 443]
@pytest.mark.asyncio
async def test_positional_action_wraps_resolver_failure(
parser: CommandArgumentParser,
) -> None:
async def fail_resolver(value: str) -> str:
raise RuntimeError(f"cannot resolve {value}")
parser.add_argument(
"target",
action="action",
resolver=Action("Resolve target", fail_resolver),
lazy_resolver=False,
)
with pytest.raises(
CommandArgumentError, match=r"\[target\] action failed: cannot resolve web"
):
await parser.parse_args(["web"])
@pytest.mark.asyncio
async def test_dash_prefixed_numeric_token_can_be_a_positional_value(
parser: CommandArgumentParser,
) -> None:
parser.add_argument("delta", type=int)
result = await parser.parse_args(["-3"])
assert result["delta"] == -3
@pytest.mark.asyncio
async def test_store_option_without_value_raises_type_specific_prompt(
parser: CommandArgumentParser,
) -> None:
parser.add_argument("--count", type=int, help="Number of instances.")
with pytest.raises(CommandArgumentError, match="enter a int value for 'count'"):
await parser.parse_args(["--count"])
@pytest.mark.asyncio
async def test_append_option_without_value_raises_type_specific_prompt(
parser: CommandArgumentParser,
) -> None:
parser.add_argument("--tag", action="append", help="Deployment tag.")
with pytest.raises(CommandArgumentError, match="enter a str value for 'tag'"):
await parser.parse_args(["--tag"])
@pytest.mark.asyncio
async def test_tldr_flag_on_help_command_is_parsed_as_a_normal_value(
parser: CommandArgumentParser,
) -> None:
parser.mark_as_help_command()
parser.add_tldr_example("D --region us-east", "Deploy to us-east")
result = await parser.parse_args(["--tldr"])
assert result["tldr"] is True
@pytest.mark.asyncio
async def test_tldr_flag_renders_examples_and_raises_help_signal(
parser: CommandArgumentParser,
) -> None:
stream = capture_console(parser)
parser.add_tldr_example("--region us-east", "Deploy to us-east")
with pytest.raises(HelpSignal):
await parser.parse_args(["--tldr"])
output = stream.getvalue()
assert "usage:" in output
assert "examples:" in output
assert "Deploy to us-east" in output
assert "--region us-east" in output
@pytest.mark.asyncio
async def test_required_mutex_group_requires_one_member(
parser: CommandArgumentParser,
) -> None:
mode = parser.add_mutually_exclusive_group("mode", required=True)
mode.add_argument("--dry-run", action="store_true")
mode.add_argument("--apply", action="store_true")
with pytest.raises(
CommandArgumentError, match="one of the following is required for group 'mode'"
):
await parser.parse_args([])
@pytest.mark.asyncio
async def test_mutex_group_rejects_multiple_present_members(
parser: CommandArgumentParser,
) -> None:
mode = parser.add_mutually_exclusive_group("mode")
mode.add_argument("--dry-run", action="store_true")
mode.add_argument("--apply", action="store_true")
with pytest.raises(
CommandArgumentError,
match="cannot be used together: (dry_run, apply|apply, dry_run)",
):
await parser.parse_args(["--dry-run", "--apply"])
@pytest.mark.parametrize(
("argument_kwargs", "argv"),
[
({"flags": ("--enabled",), "action": "store_true"}, ["--enabled", "--other"]),
({"flags": ("--disabled",), "action": "store_false"}, ["--disabled", "--other"]),
(
{"flags": ("--feature",), "action": "store_bool_optional"},
["--no-feature", "--other"],
),
({"flags": ("-v", "--verbose"), "action": "count"}, ["-v", "--other"]),
({"flags": ("--tag",), "action": "append"}, ["--tag", "beta", "--other"]),
(
{"flags": ("--item",), "action": "extend", "nargs": "+"},
["--item", "a", "--other"],
),
({"flags": ("--name",)}, ["--name", "web", "--other"]),
],
)
@pytest.mark.asyncio
async def test_mutex_presence_detection_handles_all_supported_action_shapes(
argument_kwargs: dict,
argv: list[str],
) -> None:
parser = CommandArgumentParser(command_key="D")
only_one = parser.add_mutually_exclusive_group("only-one")
kwargs = argument_kwargs.copy()
flags = kwargs.pop("flags")
only_one.add_argument(*flags, **kwargs)
only_one.add_argument("--other", action="store_true")
with pytest.raises(CommandArgumentError, match="cannot be used together"):
await parser.parse_args(argv)
@pytest.mark.asyncio
async def test_parse_args_split_separates_execution_options_from_command_inputs(
parser: CommandArgumentParser,
) -> None:
parser.add_argument("service")
parser.add_argument("--region", default="us-east")
parser.enable_execution_options(
frozenset(
{
ExecutionOption.SUMMARY,
ExecutionOption.RETRY,
ExecutionOption.CONFIRM,
}
)
)
args, kwargs, execution_args = await parser.parse_args_split(
[
"api",
"--region",
"us-west",
"--summary",
"--retries",
"3",
"--retry-delay",
"0.5",
"--retry-backoff",
"2.0",
"--skip-confirm",
]
)
assert args == ("api",)
assert kwargs == {"region": "us-west"}
assert execution_args == {
"summary": True,
"retries": 3,
"retry_delay": 0.5,
"retry_backoff": 2.0,
"force_confirm": False,
"skip_confirm": True,
}
@pytest.mark.asyncio
async def test_lazy_action_required_argument_is_deferred_during_validation(
parser: CommandArgumentParser,
) -> None:
calls: list[str] = []
async def resolve(value: str) -> str:
calls.append(value)
return value.upper()
parser.add_argument(
"--target",
action="action",
resolver=Action("Resolve target", resolve),
required=True,
)
result = await parser.parse_args(["--target", "web"], from_validate=True)
assert result["target"] is None
assert calls == []
@pytest.mark.asyncio
async def test_lazy_action_required_argument_still_errors_when_no_tokens_are_present(
parser: CommandArgumentParser,
) -> None:
async def resolve(value: str) -> str:
return value.upper()
parser.add_argument(
"--target",
action="action",
resolver=Action("Resolve target", resolve),
required=True,
)
with pytest.raises(CommandArgumentError, match="missing required argument 'target'"):
await parser.parse_args([], from_validate=True)
@pytest.mark.asyncio
async def test_default_list_with_wrong_fixed_nargs_arity_is_invalid(
parser: CommandArgumentParser,
) -> None:
parser.add_argument("--pair", nargs=2, default=["only-one"])
with pytest.raises(InvalidValueError) as exc_info:
await parser.parse_args([])
assert exc_info.value.dest == "pair"
assert "expected 2" in str(exc_info.value)
@pytest.mark.asyncio
async def test_required_plus_nargs_option_requires_at_least_one_value(
parser: CommandArgumentParser,
) -> None:
parser.add_argument("--item", nargs="+", required=True)
with pytest.raises(
CommandArgumentError, match="argument 'item' requires at least one value"
):
await parser.parse_args([])
@pytest.mark.asyncio
async def test_suggest_next_filters_mutex_siblings_after_one_member_is_consumed(
parser: CommandArgumentParser,
) -> None:
mode = parser.add_mutually_exclusive_group("mode")
mode.add_argument("--dry-run", action="store_true")
mode.add_argument("--apply", action="store_true")
parser.add_argument("--region", choices=["us-east", "us-west"])
await parser.parse_args(["--dry-run"])
suggestions = parser.suggest_next(["--dry-run"], cursor_at_end_of_token=True)
assert "--apply" not in suggestions
assert "--region" in suggestions
@pytest.mark.asyncio
async def test_optional_choice_argument_can_be_omitted(
parser: CommandArgumentParser,
) -> None:
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--region", choices=["us-east", "us-west"])
result = await parser.parse_args(["--dry-run"])
assert result["dry_run"] is True
assert result["region"] is None
@pytest.mark.asyncio
async def test_suggest_next_returns_no_values_after_invalid_choice_is_committed(
parser: CommandArgumentParser,
) -> None:
parser.add_argument("--region", choices=["dev", "prod"])
with pytest.raises(InvalidValueError):
await parser.parse_args(["--region", "qa"])
assert parser.suggest_next(["--region", "qa"], cursor_at_end_of_token=True) == []
@pytest.mark.asyncio
async def test_suggest_next_suggests_value_for_keyword_when_stub_starts_with_dash(
parser: CommandArgumentParser,
) -> None:
parser.add_argument("--profile", suggestions=["-prod", "-stage", "dev"])
with pytest.raises(CommandArgumentError):
await parser.parse_args(["--profile"], from_validate=True)
assert parser.suggest_next(["--profile", "-"], cursor_at_end_of_token=False) == [
"-prod",
"-stage",
]
@pytest.mark.asyncio
async def test_suggest_next_returns_empty_for_missing_path_base(
parser: CommandArgumentParser,
tmp_path: Path,
) -> None:
missing = tmp_path / "missing-dir" / "config.toml"
parser.add_argument("--config", type=Path)
await parser.parse_args(["--config", str(missing)], from_validate=True)
assert (
parser.suggest_next(["--config", str(missing)], cursor_at_end_of_token=False)
== []
)
def test_get_options_text_repeats_fixed_width_positional_nargs(
parser: CommandArgumentParser,
) -> None:
parser.add_argument("coords", nargs=2)
assert "coords coords" in parser.get_options_text()
def test_get_usage_uses_program_only_when_parser_is_in_runner_mode(
parser: CommandArgumentParser,
) -> None:
parser.is_runner_mode = True
parser.program = "deploy-tool"
usage = parser.get_usage()
assert "deploy-tool" in usage
assert "[bold]D[/bold]" not in usage
assert "[bold]deploy[/bold]" not in usage
def test_render_help_includes_grouped_keywords_bool_optional_pair_and_epilog(
parser: CommandArgumentParser,
) -> None:
stream = capture_console(parser)
parser.add_argument("environment", help="Target environment.")
deploy = parser.add_argument_group("deploy", "Deployment options.")
deploy.add_argument("--region", help="Target region.")
mode = parser.add_mutually_exclusive_group("mode")
mode.add_argument("--dry-run", action="store_true", help="Preview only.")
parser.add_argument("--cache", action="store_bool_optional", help="Use cache.")
parser.render_help()
output = stream.getvalue()
assert "usage:" in output
assert "Deploy a service." in output
assert "positional:" in output
assert "environment" in output
assert "deploy:" in output
assert "Deployment options." in output
assert "--cache, --no-cache" in output
assert "Deployment epilog." in output
def test_render_tldr_without_examples_prints_empty_state_message(
parser: CommandArgumentParser,
) -> None:
stream = capture_console(parser)
parser.render_tldr()
assert "No TLDR examples available for D" in stream.getvalue()
def test_render_tldr_with_examples_prints_usage_help_and_example_panel(
parser: CommandArgumentParser,
) -> None:
stream = capture_console(parser)
parser.add_tldr_example("--region us-east", "Deploy east")
parser.render_tldr()
output = stream.getvalue()
assert "usage:" in output
assert "Deploy a service." in output
assert "examples:" in output
assert "Deploy east" in output
assert "--region us-east" in output

View File

@@ -34,10 +34,6 @@ def test_enable_execution_options_registers_retry_flags():
def test_enable_execution_options_invalid_double_registration_raises(): def test_enable_execution_options_invalid_double_registration_raises():
parser = CommandArgumentParser() parser = CommandArgumentParser()
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY})) 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( with pytest.raises(
CommandArgumentError, CommandArgumentError,
@@ -68,9 +64,10 @@ def test_register_execution_dest_rejects_duplicates():
parser.add_argument("--summary", action="store_true") parser.add_argument("--summary", action="store_true")
with pytest.raises( 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 @pytest.mark.asyncio

View File

@@ -76,9 +76,10 @@ async def test_resolve_args_raises_on_conflicting_execution_option():
) )
with pytest.raises( 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 @pytest.mark.asyncio

View File

@@ -1,10 +1,12 @@
import asyncio import asyncio
import logging
import sys import sys
import pytest import pytest
from rich.console import Console from rich.console import Console
from rich.text import Text from rich.text import Text
from falyx import Falyx
from falyx.action import Action from falyx.action import Action
from falyx.command import Command from falyx.command import Command
from falyx.command_runner import CommandRunner from falyx.command_runner import CommandRunner
@@ -18,6 +20,7 @@ from falyx.exceptions import (
) )
from falyx.hook_manager import HookManager, HookType from falyx.hook_manager import HookManager, HookType
from falyx.options_manager import OptionsManager from falyx.options_manager import OptionsManager
from falyx.parser import CommandArgumentParser
from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal 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.command == command_with_parser
assert runner.program == "test_program" assert runner.program == "test_program"
assert runner.command.arg_parser.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 isinstance(runner.runner_hooks, HookManager)
assert runner.console == falyx_console assert runner.console == falyx_console
assert runner.command.options_manager == runner.options assert runner.command.options_manager == runner.options_manager
assert runner.command.arg_parser.options_manager == runner.options assert runner.command.arg_parser.options_manager == runner.options_manager
assert runner.command.options_manager == runner.options assert runner.command.options_manager == runner.options_manager
assert runner.executor.options == runner.options assert runner.executor.options_manager == runner.options_manager
assert runner.executor.hooks == runner.runner_hooks 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) runner_no_parser = CommandRunner(command_with_no_parser)
assert runner_no_parser.command == 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): def test_command_runner_initialization_with_custom_options(command_with_parser):
custom_options = OptionsManager([("default", {"summary": True})]) custom_options = OptionsManager([("default", {"summary": True})])
runner = CommandRunner(command_with_parser, options=custom_options) runner = CommandRunner(command_with_parser, options_manager=custom_options)
assert runner.options == custom_options assert runner.options_manager == custom_options
assert runner.options.get("summary", namespace_name="default") is True assert runner.options_manager.get("summary", namespace_name="default") is True
assert runner.command.options_manager == runner.options assert runner.command.options_manager == runner.options_manager
assert runner.command.arg_parser.options_manager == runner.options assert runner.command.arg_parser.options_manager == runner.options_manager
assert runner.command.options_manager == runner.options assert runner.command.options_manager == runner.options_manager
def test_command_runner_initialization_with_custom_console(command_with_parser): 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" custom_hooks = "Not a HookManager"
with pytest.raises( with pytest.raises(
NotAFalyxError, match="options must be an instance of OptionsManager" NotAFalyxError, match="options_manager must be an instance of OptionsManager"
): ):
CommandRunner( CommandRunner(
command_with_parser, command_with_parser,
options=custom_options, options_manager=custom_options,
) )
with pytest.raises( with pytest.raises(
@@ -247,9 +250,10 @@ async def test_command_runner_run_with_failing_action(command_with_failing_actio
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_command_runner_debug_statement(command_with_parser, caplog): 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) runner = CommandRunner(command_with_parser)
await runner.run("--foo 42") await runner.run("--foo 42")
print(command_with_parser.get_option("verbose", namespace_name="root"))
assert ( assert (
"Executing command 'Test Command' with args=(), kwargs={'foo': 42}" in caplog.text "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=False)
await runner.run(["--foo", "42"], raise_on_error=False, wrap_errors=True) await runner.run(["--foo", "42"], raise_on_error=False, wrap_errors=True)
await runner.run(["--foo", "42"], raise_on_error=True, wrap_errors=False) await runner.run(["--foo", "42"], raise_on_error=True, wrap_errors=False)
def test_command_runner_from_command_reuses_custom_options_manager_and_seeds_missing_namespaces():
flx = Falyx(program="source")
original = flx.add_command(
key="D",
description="Deploy",
action=lambda: "ok",
)
custom_options = OptionsManager([("default", {"summary": True})])
runner = CommandRunner.from_command(
original,
options_manager=custom_options,
)
assert runner.command is not original
assert runner.options_manager is custom_options
assert runner.command.options_manager is custom_options
assert runner.options_manager.get("summary", namespace_name="default") is True
assert runner.options_manager.get_namespace("root") == {}
assert runner.options_manager.get_namespace("execution") == {}
assert original.options_manager is flx.options_manager
assert original.options_manager is not custom_options
@pytest.mark.asyncio
async def test_command_runner_root_options_affect_cloned_command_without_mutating_original(
monkeypatch,
):
flx = Falyx(program="source")
original = flx.add_command(
key="D",
description="Deploy",
action=Action("deploy-action", lambda: "ok"),
confirm=True,
)
original.options_manager.set("never_prompt", False, "root")
original.options_manager.set("verbose", False, "root")
runner_options = OptionsManager()
runner_options.from_mapping({}, "root")
runner_options.from_mapping({}, "execution")
runner_options.set("never_prompt", True, "root")
runner_options.set("verbose", True, "root")
runner = CommandRunner.from_command(
original,
options_manager=runner_options,
)
calls = {"preview": 0, "confirm": 0}
async def fake_preview(self):
calls["preview"] += 1
async def fake_confirm(*args, **kwargs):
calls["confirm"] += 1
return True
monkeypatch.setattr(Command, "preview", fake_preview)
monkeypatch.setattr("falyx.command.confirm_async", fake_confirm)
result = await runner.run([])
assert result == "ok"
assert calls["preview"] == 0
assert calls["confirm"] == 0
assert runner.command.get_option("never_prompt", namespace_name="root") is True
assert runner.command.get_option("verbose", namespace_name="root") is True
assert runner.command.action.get_option("verbose", namespace_name="root") is True
assert runner.command.action.never_prompt is True
assert original.get_option("never_prompt", namespace_name="root") is False
assert original.get_option("verbose", namespace_name="root") is False
assert original.options_manager is flx.options_manager
assert original.options_manager is not runner.options_manager
@pytest.mark.asyncio
async def test_command_runner_from_command_with_custom_options_preserves_parity_and_isolation():
falyx = Falyx("Custom Options Parity Test")
def add(x: int, y: int) -> int:
return x + y
command = falyx.add_command(
key="A",
description="Add",
action=add,
)
custom_options = OptionsManager([("default", {"summary": True})])
runner = CommandRunner.from_command(
command,
options_manager=custom_options,
)
falyx_result = await falyx.execute_command("A 2 3")
runner_result = await runner.run(["2", "3"])
assert falyx_result == 5
assert runner_result == 5
assert falyx_result == runner_result
assert runner.options_manager is custom_options
assert runner.command.options_manager is custom_options
assert runner.options_manager.get("summary", namespace_name="default") is True
assert runner.options_manager.get_namespace("root") == {}
assert runner.options_manager.get_namespace("execution") == {}
assert runner.command is not command
assert command.options_manager is falyx.options_manager
assert command.options_manager is not runner.options_manager

489
tests/test_selection.py Normal file
View File

@@ -0,0 +1,489 @@
from __future__ import annotations
from typing import Any
import pytest
from rich import box
from rich.table import Table
import falyx.selection as selection_module
from falyx.selection import (
SelectionOption,
SelectionOptionMap,
get_selection_from_dict_menu,
prompt_for_index,
prompt_for_selection,
render_selection_dict_table,
render_selection_grid,
render_selection_indexed_table,
render_table_base,
select_key_from_dict,
select_value_from_dict,
select_value_from_list,
)
from falyx.themes import OneColors
class CaptureConsole:
def __init__(self) -> None:
self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
def print(self, *args: Any, **kwargs: Any) -> None:
self.printed.append((args, kwargs))
class FakePromptSession:
def __init__(self, *responses: str) -> None:
self.responses = list(responses)
self.calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
self.message: Any = "original-message"
self.validator: Any = "original-validator"
self.placeholder: Any = "original-placeholder"
async def prompt_async(self, *args: Any, **kwargs: Any) -> str:
self.calls.append((args, kwargs))
self.message = kwargs.get("message")
self.validator = kwargs.get("validator")
self.placeholder = kwargs.get("placeholder")
if not self.responses:
raise AssertionError("No fake prompt response configured")
return self.responses.pop(0)
@pytest.fixture
def sample_options() -> dict[str, SelectionOption]:
return {
"dev": SelectionOption("Development", "dev-value", style="green"),
"prod": SelectionOption("Production", "prod-value", style="red"),
"stage": SelectionOption("Staging", "stage-value", style="yellow"),
}
def test_selection_option_rejects_non_string_description() -> None:
with pytest.raises(TypeError, match="description must be a string"):
SelectionOption(123, "value") # type: ignore[arg-type]
def test_selection_option_render_escapes_key_and_applies_style() -> None:
option = SelectionOption("Deploy [prod]", "prod", style="red")
rendered = option.render("a[b]")
assert "a" in rendered
assert "Deploy [prod]" in rendered
assert f"[{OneColors.WHITE}]" in rendered
assert "[red]" in rendered
def test_selection_option_copy_returns_independent_equivalent_option() -> None:
option = SelectionOption("Development", {"env": "dev"}, style="green")
copied = option.copy()
assert copied == option
assert copied is not option
def test_selection_option_map_initializes_from_options_case_insensitively(
sample_options: dict[str, SelectionOption],
) -> None:
mapping = SelectionOptionMap({"dev": sample_options["dev"]})
assert mapping["DEV"] is sample_options["dev"]
assert mapping["dev"] is sample_options["dev"]
assert list(mapping.items()) == [("DEV", sample_options["dev"])]
def test_selection_option_map_rejects_non_selection_option_values() -> None:
mapping = SelectionOptionMap()
with pytest.raises(TypeError, match="must be a SelectionOption"):
mapping["bad"] = "not an option" # type: ignore[assignment]
with pytest.raises(TypeError, match="must be a SelectionOption"):
mapping.update({"bad": "not an option"})
with pytest.raises(TypeError, match="must be a SelectionOption"):
mapping.update(bad="not an option")
def test_selection_option_map_update_accepts_kwargs_and_copy_is_deep_for_options(
sample_options: dict[str, SelectionOption],
) -> None:
mapping = SelectionOptionMap()
mapping.update(dev=sample_options["dev"])
mapping.update({"prod": sample_options["prod"]})
copied = mapping.copy()
assert copied.allow_reserved is mapping.allow_reserved
assert copied["DEV"] == mapping["dev"]
assert copied["DEV"] is not mapping["dev"]
assert copied["PROD"].description == "Production"
def test_selection_option_map_reserved_key_protection_and_bypass(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(SelectionOptionMap, "RESERVED_KEYS", {"QUIT"})
mapping = SelectionOptionMap()
reserved_option = SelectionOption("Quit", None)
with pytest.raises(ValueError, match="reserved"):
mapping["quit"] = reserved_option
mapping._add_reserved("quit", reserved_option)
assert mapping["QUIT"] is reserved_option
with pytest.raises(ValueError, match="Cannot delete reserved option"):
del mapping["quit"]
assert list(mapping.items(include_reserved=False)) == []
def test_selection_option_map_allows_reserved_key_when_configured(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(SelectionOptionMap, "RESERVED_KEYS", {"QUIT"})
mapping = SelectionOptionMap(allow_reserved=True)
option = SelectionOption("Quit", None)
mapping["quit"] = option
assert mapping["QUIT"] is option
del mapping["quit"]
assert "QUIT" not in mapping
def test_render_table_base_uses_explicit_column_names_and_styles() -> None:
table = render_table_base(
"Environments",
caption="Choose carefully",
column_names=["Name", "Description"],
box_style=box.ROUNDED,
show_lines=True,
show_header=True,
show_footer=True,
style="blue",
header_style="bold",
footer_style="dim",
title_style="green",
caption_style="yellow",
highlight=False,
)
assert isinstance(table, Table)
assert table.title == "Environments"
assert table.caption == "Choose carefully"
assert len(table.columns) == 2
assert table.columns[0].header == "Name"
assert table.columns[1].header == "Description"
assert table.show_header is True
assert table.show_footer is True
assert table.highlight is False
def test_render_table_base_creates_blank_columns_when_no_names_are_given() -> None:
table = render_table_base("Choices", columns=3)
assert len(table.columns) == 3
def test_render_selection_grid_chunks_rows() -> None:
table = render_selection_grid("Choices", ["alpha", "beta", "gamma"], columns=2)
assert table.title == "Choices"
assert len(table.columns) == 2
assert len(table.rows) == 2
def test_render_selection_indexed_table_uses_default_and_custom_formatters() -> None:
default_table = render_selection_indexed_table(
"Indexed", ["alpha", "beta", "gamma"], columns=2
)
formatted_table = render_selection_indexed_table(
"Formatted",
["alpha", "beta"],
columns=2,
formatter=lambda index, value: f"{index}:{value.upper()}",
)
assert len(default_table.rows) == 2
assert len(formatted_table.rows) == 1
def test_render_selection_dict_table_renders_option_rows(
sample_options: dict[str, SelectionOption],
) -> None:
table = render_selection_dict_table(
"Environments",
sample_options,
columns=2,
caption="Pick one",
highlight=True,
)
assert table.title == "Environments"
assert table.caption == "Pick one"
assert len(table.columns) == 2
assert len(table.rows) == 2
assert table.highlight is True
@pytest.mark.asyncio
async def test_prompt_for_index_returns_single_index_and_restores_prompt_state(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_console = CaptureConsole()
monkeypatch.setattr(selection_module, "console", fake_console)
session = FakePromptSession(" 2 ")
table = render_table_base("Choices")
result = await prompt_for_index(
5,
table,
default_selection="1",
prompt_session=session, # type: ignore[arg-type]
prompt_message="[bold]Pick >[/] ",
show_table=True,
number_selections=1,
cancel_key="9",
)
assert result == 2
assert len(fake_console.printed) == 1
_, kwargs = session.calls[0]
assert kwargs["default"] == "1"
assert kwargs["placeholder"] == "Enter selection"
assert kwargs["validator"].__class__.__name__ == "MultiIndexValidator"
assert session.message == "original-message"
assert session.validator == "original-validator"
assert session.placeholder == "original-placeholder"
@pytest.mark.asyncio
async def test_prompt_for_index_returns_cancel_key_as_int() -> None:
session = FakePromptSession(" 7 ")
table = render_table_base("Choices")
result = await prompt_for_index(
7,
table,
prompt_session=session, # type: ignore[arg-type]
show_table=False,
cancel_key="7",
)
assert result == 7
@pytest.mark.asyncio
async def test_prompt_for_index_returns_multiple_indexes_with_custom_separator() -> None:
session = FakePromptSession("0 ; 2 ; 4")
table = render_table_base("Choices")
result = await prompt_for_index(
5,
table,
prompt_session=session, # type: ignore[arg-type]
show_table=False,
number_selections=3,
separator=";",
allow_duplicates=True,
)
assert result == [0, 2, 4]
_, kwargs = session.calls[0]
assert kwargs["placeholder"] == "Enter 3 selections separated by ';'"
assert kwargs["validator"].__class__.__name__ == "MultiIndexValidator"
@pytest.mark.asyncio
async def test_prompt_for_selection_returns_single_key_and_prints_table(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_console = CaptureConsole()
monkeypatch.setattr(selection_module, "console", fake_console)
session = FakePromptSession(" DEV ")
table = render_table_base("Choices")
result = await prompt_for_selection(
["DEV", "PROD"],
table,
default_selection="PROD",
prompt_session=session, # type: ignore[arg-type]
prompt_message="Select > ",
show_table=True,
number_selections=1,
cancel_key="Q",
)
assert result == "DEV"
assert len(fake_console.printed) == 1
_, kwargs = session.calls[0]
assert kwargs["default"] == "PROD"
assert kwargs["placeholder"] == "Enter selection"
assert kwargs["validator"].__class__.__name__ == "MultiKeyValidator"
@pytest.mark.asyncio
async def test_prompt_for_selection_returns_cancel_key() -> None:
session = FakePromptSession(" q ")
table = render_table_base("Choices")
result = await prompt_for_selection(
["dev", "prod", "q"],
table,
prompt_session=session, # type: ignore[arg-type]
show_table=False,
cancel_key="q",
)
assert result == "q"
@pytest.mark.asyncio
async def test_prompt_for_selection_returns_multiple_keys_with_custom_separator() -> None:
session = FakePromptSession("dev | prod | stage")
table = render_table_base("Choices")
result = await prompt_for_selection(
["dev", "prod", "stage"],
table,
prompt_session=session, # type: ignore[arg-type]
show_table=False,
number_selections="*",
separator="|",
allow_duplicates=True,
)
assert result == ["dev", "prod", "stage"]
_, kwargs = session.calls[0]
assert kwargs["placeholder"] == "Enter selections separated by '|'"
@pytest.mark.asyncio
async def test_select_value_from_list_returns_single_and_multiple_values(
monkeypatch: pytest.MonkeyPatch,
) -> None:
prompt_calls: list[dict[str, Any]] = []
async def fake_prompt_for_index(max_index: int, table: Table, **kwargs: Any) -> Any:
prompt_calls.append({"max_index": max_index, "table": table, **kwargs})
return [0, 2] if kwargs["number_selections"] != 1 else 1
monkeypatch.setattr(selection_module, "prompt_for_index", fake_prompt_for_index)
single = await select_value_from_list(
"Languages",
["python", "rust", "go"],
default_selection="1",
number_selections=1,
)
multiple = await select_value_from_list(
"Languages",
["python", "rust", "go"],
default_selection="0,2",
number_selections=2,
)
assert single == "rust"
assert multiple == ["python", "go"]
assert prompt_calls[0]["max_index"] == 2
assert prompt_calls[0]["default_selection"] == "1"
assert prompt_calls[1]["number_selections"] == 2
@pytest.mark.asyncio
async def test_select_key_from_dict_delegates_to_prompt_for_selection(
monkeypatch: pytest.MonkeyPatch,
sample_options: dict[str, SelectionOption],
) -> None:
fake_console = CaptureConsole()
monkeypatch.setattr(selection_module, "console", fake_console)
calls: list[dict[str, Any]] = []
async def fake_prompt_for_selection(keys: Any, table: Table, **kwargs: Any) -> str:
calls.append({"keys": list(keys), "table": table, **kwargs})
return "prod"
monkeypatch.setattr(
selection_module, "prompt_for_selection", fake_prompt_for_selection
)
table = render_table_base("Environments")
result = await select_key_from_dict(
sample_options,
table,
default_selection="dev",
number_selections=1,
cancel_key="q",
)
assert result == "prod"
assert len(fake_console.printed) == 1
assert calls[0]["keys"] == ["dev", "prod", "stage"]
assert calls[0]["default_selection"] == "dev"
assert calls[0]["cancel_key"] == "q"
@pytest.mark.asyncio
async def test_select_value_from_dict_returns_single_and_multiple_values(
monkeypatch: pytest.MonkeyPatch,
sample_options: dict[str, SelectionOption],
) -> None:
fake_console = CaptureConsole()
monkeypatch.setattr(selection_module, "console", fake_console)
responses: list[Any] = ["prod", ["dev", "stage"]]
async def fake_prompt_for_selection(keys: Any, table: Table, **kwargs: Any) -> Any:
return responses.pop(0)
monkeypatch.setattr(
selection_module, "prompt_for_selection", fake_prompt_for_selection
)
table = render_table_base("Environments")
single = await select_value_from_dict(sample_options, table)
multiple = await select_value_from_dict(
sample_options,
table,
number_selections=2,
separator=",",
)
assert single == "prod-value"
assert multiple == ["dev-value", "stage-value"]
assert len(fake_console.printed) == 2
@pytest.mark.asyncio
async def test_get_selection_from_dict_menu_builds_table_and_returns_selected_value(
monkeypatch: pytest.MonkeyPatch,
sample_options: dict[str, SelectionOption],
) -> None:
calls: list[dict[str, Any]] = []
async def fake_select_value_from_dict(
*, selections: dict[str, SelectionOption], table: Table, **kwargs: Any
) -> str:
calls.append({"selections": selections, "table": table, **kwargs})
return "prod-value"
monkeypatch.setattr(
selection_module, "select_value_from_dict", fake_select_value_from_dict
)
result = await get_selection_from_dict_menu(
"Environments",
sample_options,
default_selection="prod",
number_selections=1,
cancel_key="q",
)
assert result == "prod-value"
assert calls[0]["selections"] is sample_options
assert calls[0]["table"].title == "Environments"
assert calls[0]["default_selection"] == "prod"
assert calls[0]["cancel_key"] == "q"