From efe3f5fd99f6779812ef0c3b63b8169dd5050b33 Mon Sep 17 00:00:00 2001 From: Roland Thomas Date: Sun, 7 Jun 2026 13:04:35 -0400 Subject: [PATCH] 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 --- falyx/action/action.py | 31 + falyx/action/action_factory.py | 15 + falyx/action/action_group.py | 21 + falyx/action/action_mixins.py | 2 +- falyx/action/base_action.py | 22 +- falyx/action/chained_action.py | 23 + falyx/action/confirm_action.py | 16 +- falyx/action/fallback_action.py | 6 + falyx/action/http_action.py | 28 + falyx/action/io_action.py | 11 + falyx/action/literal_input_action.py | 4 + falyx/action/load_file_action.py | 13 + falyx/action/menu_action.py | 22 +- falyx/action/process_action.py | 18 + falyx/action/process_pool_action.py | 20 + falyx/action/prompt_menu_action.py | 16 + falyx/action/save_file_action.py | 27 +- falyx/action/select_file_action.py | 25 +- falyx/action/selection_action.py | 24 +- falyx/action/shell_action.py | 12 + falyx/action/signal_action.py | 6 + falyx/action/user_input_action.py | 14 + falyx/command.py | 169 +++- falyx/command_executor.py | 8 +- falyx/command_runner.py | 58 +- falyx/completer.py | 44 +- falyx/completer_types.py | 4 + falyx/console.py | 4 + falyx/exceptions.py | 30 +- falyx/execution_registry.py | 2 +- falyx/falyx.py | 335 +++++-- falyx/hook_manager.py | 7 + falyx/menu.py | 18 + falyx/options_manager.py | 53 +- falyx/parser/__init__.py | 3 +- falyx/parser/argument.py | 22 + falyx/parser/command_argument_parser.py | 219 +++-- falyx/parser/falyx_parser.py | 275 +++--- falyx/parser/group.py | 21 +- falyx/parser/option.py | 41 + falyx/parser/option_action.py | 44 + falyx/parser/parse_result.py | 11 +- falyx/parser/parser_types.py | 48 +- falyx/parser/utils.py | 5 +- falyx/prompt_utils.py | 22 +- falyx/routing.py | 8 +- falyx/selection.py | 17 + falyx/utils.py | 3 + tests/test_actions/test_clone.py | 334 +++++++ tests/test_actions/test_save_file_action.py | 430 +++++++++ tests/test_actions/test_selection_action.py | 665 ++++++++++++- .../test_selection_file_action.py | 598 ++++++++++++ tests/test_command.py | 719 +++++++++++++- tests/test_context.py | 341 +++++++ tests/test_execution_registry.py | 307 ++++++ tests/test_falyx/test_builtin_root_options.py | 55 ++ .../test_falyx/test_command_clone_contract.py | 138 +++ .../test_command_prompt_contract.py | 197 ++++ tests/test_falyx/test_completion_contract.py | 121 +++ tests/test_falyx/test_dispatch_contract.py | 120 +++ tests/test_falyx/test_exceptions.py | 68 ++ tests/test_falyx/test_execute_command.py | 84 +- tests/test_falyx/test_extra.py | 856 +++++++++++++++++ tests/test_falyx/test_help.py | 2 +- .../test_options_manager_contract.py | 219 +++++ tests/test_falyx/test_prompt_contract.py | 21 + tests/test_falyx/test_routing_contract.py | 92 ++ tests/test_falyx/test_run.py | 80 +- tests/test_falyx/test_signals.py | 15 + tests/test_falyx_parser/test_falyx_parser.py | 900 ++++++++++++++++++ tests/test_hook_manager.py | 226 +++++ .../test_command_argument_parser.py | 19 +- ..._command_argument_parser_clone_contract.py | 373 ++++++++ .../test_command_argument_parser_extra.py | 459 +++++++++ .../test_execution_option_registration.py | 9 +- tests/test_parsers/test_resolve_args.py | 5 +- tests/test_runner/test_command_runner.py | 157 ++- tests/test_selection.py | 489 ++++++++++ 78 files changed, 9513 insertions(+), 433 deletions(-) create mode 100644 falyx/parser/option.py create mode 100644 falyx/parser/option_action.py create mode 100644 tests/test_actions/test_clone.py create mode 100644 tests/test_actions/test_save_file_action.py create mode 100644 tests/test_actions/test_selection_file_action.py create mode 100644 tests/test_context.py create mode 100644 tests/test_execution_registry.py create mode 100644 tests/test_falyx/test_builtin_root_options.py create mode 100644 tests/test_falyx/test_command_clone_contract.py create mode 100644 tests/test_falyx/test_command_prompt_contract.py create mode 100644 tests/test_falyx/test_completion_contract.py create mode 100644 tests/test_falyx/test_dispatch_contract.py create mode 100644 tests/test_falyx/test_exceptions.py create mode 100644 tests/test_falyx/test_extra.py create mode 100644 tests/test_falyx/test_options_manager_contract.py create mode 100644 tests/test_falyx/test_prompt_contract.py create mode 100644 tests/test_falyx/test_routing_contract.py create mode 100644 tests/test_falyx/test_signals.py create mode 100644 tests/test_falyx_parser/test_falyx_parser.py create mode 100644 tests/test_hook_manager.py create mode 100644 tests/test_parsers/test_command_argument_parser_clone_contract.py create mode 100644 tests/test_parsers/test_command_argument_parser_extra.py create mode 100644 tests/test_selection.py diff --git a/falyx/action/action.py b/falyx/action/action.py index 52edcb6..d4714f2 100644 --- a/falyx/action/action.py +++ b/falyx/action/action.py @@ -206,3 +206,34 @@ class Action(BaseAction): f"retry={self.retry_policy.enabled}, " f"rollback={self.rollback is not None})" ) + + def _copy_hooks_without_retry(self) -> HookManager: + """Create a copy of the current hooks, excluding any retry handlers.""" + new_hooks = HookManager() + for hook_type, hooks in self.hooks._hooks.items(): + for hook in hooks: + owner = getattr(hook, "__self__", None) + if not isinstance(owner, RetryHandler): + new_hooks.register(hook_type, hook) + return new_hooks + + def clone(self) -> Action: + """Create a copy of this Action with the same configuration.""" + new_action = Action( + name=self.name, + action=self._action, + rollback=self._rollback, + args=self.args, + kwargs=self.kwargs, + hooks=self._copy_hooks_without_retry(), + inject_last_result=self.inject_last_result, + inject_into=self.inject_into, + never_prompt=self.local_never_prompt, + retry=self.retry_policy.enabled, + retry_policy=self.retry_policy.model_copy(deep=True), + spinner_message=self.spinner_message, + spinner_type=self.spinner_type, + spinner_style=self.spinner_style, + spinner_speed=self.spinner_speed, + ) + return new_action diff --git a/falyx/action/action_factory.py b/falyx/action/action_factory.py index c26d89d..b7b5711 100644 --- a/falyx/action/action_factory.py +++ b/falyx/action/action_factory.py @@ -30,6 +30,8 @@ Example: inject_last_result=True, ) """ +from __future__ import annotations + from typing import Any, Callable from rich.tree import Tree @@ -174,3 +176,16 @@ class ActionFactory(BaseAction): f"factory={self._factory.__name__ if hasattr(self._factory, '__name__') else type(self._factory).__name__}, " f"args={self.args!r}, kwargs={self.kwargs!r})" ) + + def clone(self) -> ActionFactory: + """Return a copy of this ActionFactory with the same configuration.""" + return ActionFactory( + name=self.name, + factory=self._factory, + inject_last_result=self.inject_last_result, + inject_into=self.inject_into, + args=self.args, + kwargs=self.kwargs, + preview_args=self.preview_args, + preview_kwargs=self.preview_kwargs, + ) diff --git a/falyx/action/action_group.py b/falyx/action/action_group.py index 318790f..00fc057 100644 --- a/falyx/action/action_group.py +++ b/falyx/action/action_group.py @@ -244,3 +244,24 @@ class ActionGroup(BaseAction, ActionListMixin): f"inject_last_result={self.inject_last_result}, " f"inject_into={self.inject_into})" ) + + def clone(self): + """Return a copy of this ActionGroup with the same configuration.""" + cloned_actions = [ + action.clone() if isinstance(action, BaseAction) else action + for action in self.actions + ] + return ActionGroup( + name=self.name, + actions=cloned_actions, + args=self.args, + kwargs=self.kwargs, + hooks=self.hooks.copy(), + inject_last_result=self.inject_last_result, + inject_into=self.inject_into, + never_prompt=self.local_never_prompt, + spinner_message=self.spinner_message, + spinner_type=self.spinner_type, + spinner_style=self.spinner_style, + spinner_speed=self.spinner_speed, + ) diff --git a/falyx/action/action_mixins.py b/falyx/action/action_mixins.py index 51d5a8a..6784265 100644 --- a/falyx/action/action_mixins.py +++ b/falyx/action/action_mixins.py @@ -7,7 +7,7 @@ maintaining a mutable list of named actions—such as adding, removing, or retri actions by name—without duplicating logic across composite action types. """ -from typing import Any, Sequence +from typing import Sequence from falyx.action.base_action import BaseAction diff --git a/falyx/action/base_action.py b/falyx/action/base_action.py index af8bd86..4883a93 100644 --- a/falyx/action/base_action.py +++ b/falyx/action/base_action.py @@ -125,10 +125,16 @@ class BaseAction(ABC): def set_shared_context(self, shared_context: SharedContext) -> None: self.shared_context = shared_context - def get_option(self, option_name: str, default: Any = None) -> Any: + def get_option( + self, + option_name: str, + default: Any = None, + *, + namespace_name: str = "default", + ) -> Any: """Resolve an option from the OptionsManager if present, else default.""" if self.options_manager: - return self.options_manager.get(option_name, default) + return self.options_manager.get(option_name, default, namespace_name) return default @property @@ -142,7 +148,12 @@ class BaseAction(ABC): def never_prompt(self) -> bool: if self._never_prompt is not None: return self._never_prompt - return self.get_option("never_prompt", False) + return self.get_option("never_prompt", False, namespace_name="root") + + @property + def local_never_prompt(self) -> bool | None: + """Return the local never_prompt setting, which may be None if not explicitly set.""" + return self._never_prompt @property def spinner_manager(self): @@ -181,3 +192,8 @@ class BaseAction(ABC): def __repr__(self) -> str: return str(self) + + @abstractmethod + def clone(self) -> BaseAction: + """Return a copy of this action. Must be implemented by subclasses.""" + return self diff --git a/falyx/action/chained_action.py b/falyx/action/chained_action.py index 751a0b7..a74d069 100644 --- a/falyx/action/chained_action.py +++ b/falyx/action/chained_action.py @@ -318,3 +318,26 @@ class ChainedAction(BaseAction, ActionListMixin): f"args={self.args!r}, kwargs={self.kwargs!r}, " f"auto_inject={self.auto_inject}, return_list={self.return_list})" ) + + def clone(self) -> ChainedAction: + """Create a copy of this ChainedAction with the same configuration.""" + cloned_actions = [ + action.clone() if isinstance(action, BaseAction) else action + for action in self.actions + ] + return ChainedAction( + name=self.name, + actions=cloned_actions, + args=self.args, + kwargs=self.kwargs, + hooks=self.hooks.copy(), + inject_last_result=self.inject_last_result, + inject_into=self.inject_into, + auto_inject=self.auto_inject, + return_list=self.return_list, + never_prompt=self.local_never_prompt, + spinner_message=self.spinner_message, + spinner_type=self.spinner_type, + spinner_style=self.spinner_style, + spinner_speed=self.spinner_speed, + ) diff --git a/falyx/action/confirm_action.py b/falyx/action/confirm_action.py index cae49e3..10750e6 100644 --- a/falyx/action/confirm_action.py +++ b/falyx/action/confirm_action.py @@ -89,7 +89,7 @@ class ConfirmAction(BaseAction): prompt_message: str = "Confirm?", confirm_type: ConfirmType | str = ConfirmType.YES_NO, prompt_session: PromptSession | None = None, - never_prompt: bool = False, + never_prompt: bool | None = False, word: str = "CONFIRM", return_last_result: bool = False, inject_last_result: bool = True, @@ -267,3 +267,17 @@ class ConfirmAction(BaseAction): f"ConfirmAction(name={self.name}, message={self.prompt_message}, " f"confirm_type={self.confirm_type}, return_last_result={self.return_last_result})" ) + + def clone(self) -> ConfirmAction: + """Return a copy of this ConfirmAction with the same configuration.""" + return ConfirmAction( + name=self.name, + prompt_message=self.prompt_message, + confirm_type=self.confirm_type, + prompt_session=self.prompt_session, + never_prompt=self.local_never_prompt, + word=self.word, + return_last_result=self.return_last_result, + inject_last_result=self.inject_last_result, + inject_into=self.inject_into, + ) diff --git a/falyx/action/fallback_action.py b/falyx/action/fallback_action.py index 9daa8bc..75aaef8 100644 --- a/falyx/action/fallback_action.py +++ b/falyx/action/fallback_action.py @@ -35,6 +35,8 @@ Example: The `FallbackAction` ensures that even if `MaybeFetchRemoteAction` fails or returns None, `ProcessDataAction` still receives a usable input. """ +from __future__ import annotations + from functools import cached_property from typing import Any @@ -83,3 +85,7 @@ class FallbackAction(Action): def __str__(self) -> str: return f"FallbackAction(fallback={self.fallback!r})" + + def clone(self) -> FallbackAction: + """Return a copy of this FallbackAction with the same fallback value.""" + return FallbackAction(fallback=self.fallback) diff --git a/falyx/action/http_action.py b/falyx/action/http_action.py index 7099460..60ee22f 100644 --- a/falyx/action/http_action.py +++ b/falyx/action/http_action.py @@ -7,6 +7,9 @@ Features: - Retry integration and last_result injection - Clean resource teardown using hooks """ +from __future__ import annotations + +from copy import deepcopy from typing import Any import aiohttp @@ -80,6 +83,7 @@ class HTTPAction(Action): spinner_type: str = "dots", spinner_style: str = OneColors.CYAN, spinner_speed: float = 1.0, + never_prompt: bool | None = None, ): self.method = method.upper() self.url = url @@ -103,6 +107,7 @@ class HTTPAction(Action): spinner_type=spinner_type, spinner_style=spinner_style, spinner_speed=spinner_speed, + never_prompt=never_prompt, ) async def _request(self, *_, **__) -> dict[str, Any]: @@ -165,3 +170,26 @@ class HTTPAction(Action): f"data={self.data!r}, retry={self.retry_policy.enabled}, " f"inject_last_result={self.inject_last_result})" ) + + def clone(self) -> HTTPAction: + """Return a copy of this HTTPAction with the same configuration.""" + return HTTPAction( + name=self.name, + method=self.method, + url=self.url, + headers=self.headers.copy() if self.headers else None, + params=self.params.copy() if self.params else None, + json=deepcopy(self.json), + data=self.data, + hooks=self._copy_hooks_without_retry(), + inject_last_result=self.inject_last_result, + inject_into=self.inject_into, + retry=self.retry_policy.enabled, + retry_policy=self.retry_policy.model_copy(deep=True), + spinner=False, + spinner_message=self.spinner_message, + spinner_type=self.spinner_type, + spinner_style=self.spinner_style, + spinner_speed=self.spinner_speed, + never_prompt=self.local_never_prompt, + ) diff --git a/falyx/action/io_action.py b/falyx/action/io_action.py index b6b393c..6fc56ba 100644 --- a/falyx/action/io_action.py +++ b/falyx/action/io_action.py @@ -14,6 +14,8 @@ Features: Common usage includes shell-like filters, input transformers, or any tool that needs to consume input from another process or pipeline. """ +from __future__ import annotations + import asyncio import sys from typing import Any, Callable @@ -168,3 +170,12 @@ class BaseIOAction(BaseAction): parent.add("".join(label)) else: self.console.print(Tree("".join(label))) + + def clone(self) -> BaseIOAction: + """Create a copy of this BaseIOAction with the same configuration.""" + return self.__class__( + name=self.name, + hooks=self.hooks.copy(), + mode=self.mode, + inject_last_result=self.inject_last_result, + ) diff --git a/falyx/action/literal_input_action.py b/falyx/action/literal_input_action.py index 2e4c3a9..20019f2 100644 --- a/falyx/action/literal_input_action.py +++ b/falyx/action/literal_input_action.py @@ -76,3 +76,7 @@ class LiteralInputAction(Action): def __str__(self) -> str: return f"LiteralInputAction(value={self.value!r})" + + def clone(self) -> LiteralInputAction: + """Create a copy of this LiteralInputAction with the same value.""" + return LiteralInputAction(self.value) diff --git a/falyx/action/load_file_action.py b/falyx/action/load_file_action.py index 7a5a770..dabaeeb 100644 --- a/falyx/action/load_file_action.py +++ b/falyx/action/load_file_action.py @@ -35,6 +35,8 @@ This module is a foundational building block for file-driven CLI workflows in Fa It is often paired with `SaveFileAction`, `SelectionAction`, or `ConfirmAction` for robust and interactive pipelines. """ +from __future__ import annotations + import csv import json import xml.etree.ElementTree as ET @@ -261,3 +263,14 @@ class LoadFileAction(BaseAction): def __str__(self) -> str: return f"LoadFileAction(file_path={self.file_path}, file_type={self.file_type})" + + def clone(self) -> LoadFileAction: + """Create a copy of this LoadFileAction with the same configuration.""" + return LoadFileAction( + name=self.name, + file_path=self.file_path, + file_type=self.file_type, + encoding=self.encoding, + inject_last_result=self.inject_last_result, + inject_into=self.inject_into, + ) diff --git a/falyx/action/menu_action.py b/falyx/action/menu_action.py index 91184bb..b793985 100644 --- a/falyx/action/menu_action.py +++ b/falyx/action/menu_action.py @@ -36,6 +36,8 @@ Example: This module is ideal for enabling structured, discoverable, and declarative menus in both interactive and programmatic CLI automation. """ +from __future__ import annotations + from typing import Any from prompt_toolkit import PromptSession @@ -119,7 +121,7 @@ class MenuAction(BaseAction): inject_last_result: bool = False, inject_into: str = "last_result", prompt_session: PromptSession | None = None, - never_prompt: bool = False, + never_prompt: bool | None = False, include_reserved: bool = True, show_table: bool = True, custom_table: Table | None = None, @@ -245,3 +247,21 @@ class MenuAction(BaseAction): f"include_reserved={self.include_reserved}, " f"prompt={'off' if self.never_prompt else 'on'})" ) + + def clone(self) -> MenuAction: + """Create a copy of this MenuAction with the same configuration.""" + return MenuAction( + name=self.name, + menu_options=self.menu_options.copy(), + title=self.title, + columns=self.columns, + prompt_message=self.prompt_message, + default_selection=self.default_selection, + inject_last_result=self.inject_last_result, + inject_into=self.inject_into, + prompt_session=self.prompt_session, + never_prompt=self.local_never_prompt, + include_reserved=self.include_reserved, + show_table=self.show_table, + custom_table=self.custom_table, + ) diff --git a/falyx/action/process_action.py b/falyx/action/process_action.py index 14a1fa4..c7bd421 100644 --- a/falyx/action/process_action.py +++ b/falyx/action/process_action.py @@ -177,3 +177,21 @@ class ProcessAction(BaseAction): f"action={getattr(self.action, '__name__', repr(self.action))}, " f"args={self.args!r}, kwargs={self.kwargs!r})" ) + + def clone(self) -> ProcessAction: + """Create a copy of this ProcessAction with the same configuration.""" + return ProcessAction( + name=self.name, + action=self.action, + args=self.args, + kwargs=self.kwargs, + hooks=self.hooks.copy(), + executor=None, + inject_last_result=self.inject_last_result, + inject_into=self.inject_into, + never_prompt=self.local_never_prompt, + spinner_message=self.spinner_message, + spinner_type=self.spinner_type, + spinner_style=self.spinner_style, + spinner_speed=self.spinner_speed, + ) diff --git a/falyx/action/process_pool_action.py b/falyx/action/process_pool_action.py index 15437eb..025d7d8 100644 --- a/falyx/action/process_pool_action.py +++ b/falyx/action/process_pool_action.py @@ -58,6 +58,14 @@ class ProcessTask: if not callable(self.task): raise TypeError(f"Expected a callable task, got {type(self.task).__name__}") + def copy(self) -> ProcessTask: + """Create a copy of this ProcessTask.""" + return ProcessTask( + task=self.task, + args=self.args, + kwargs=self.kwargs.copy(), + ) + class ProcessPoolAction(BaseAction): """Executes a set of independent tasks in parallel using a process pool. @@ -230,3 +238,15 @@ class ProcessPoolAction(BaseAction): f"inject_last_result={self.inject_last_result}, " f"inject_into={self.inject_into!r})" ) + + def clone(self) -> ProcessPoolAction: + """Create a copy of this ProcessPoolAction with the same configuration.""" + cloned = ProcessPoolAction( + name=self.name, + actions=[action.copy() for action in self.actions], + hooks=self.hooks.copy(), + executor=None, + inject_last_result=self.inject_last_result, + inject_into=self.inject_into, + ) + return cloned diff --git a/falyx/action/prompt_menu_action.py b/falyx/action/prompt_menu_action.py index b423ce1..c7f47ff 100644 --- a/falyx/action/prompt_menu_action.py +++ b/falyx/action/prompt_menu_action.py @@ -10,6 +10,8 @@ or contextual user input flows. Key Components: - PromptMenuAction: Inline prompt-driven menu runner """ +from __future__ import annotations + from typing import Any from prompt_toolkit import PromptSession @@ -187,3 +189,17 @@ class PromptMenuAction(BaseAction): f"include_reserved={self.include_reserved}, " f"prompt={'off' if self.never_prompt else 'on'})" ) + + def clone(self) -> PromptMenuAction: + """Create a copy of this PromptMenuAction with the same configuration.""" + return PromptMenuAction( + name=self.name, + menu_options=self.menu_options.copy(), + prompt_message=self.prompt_message, + default_selection=self.default_selection, + inject_last_result=self.inject_last_result, + inject_into=self.inject_into, + prompt_session=self.prompt_session, + never_prompt=self.never_prompt, + include_reserved=self.include_reserved, + ) diff --git a/falyx/action/save_file_action.py b/falyx/action/save_file_action.py index 99493c3..a20353e 100644 --- a/falyx/action/save_file_action.py +++ b/falyx/action/save_file_action.py @@ -19,6 +19,8 @@ Common use cases: - Logging artifacts from batch pipelines - Exporting config or user input to JSON/YAML for reuse """ +from __future__ import annotations + import csv import json import xml.etree.ElementTree as ET @@ -89,7 +91,7 @@ class SaveFileAction(BaseAction): def __init__( self, name: str, - file_path: str, + file_path: str | Path | None, file_type: FileType | str = FileType.TEXT, mode: Literal["w", "a"] = "w", encoding: str = "UTF-8", @@ -98,6 +100,7 @@ class SaveFileAction(BaseAction): create_dirs: bool = True, inject_last_result: bool = False, inject_into: str = "data", + never_prompt: bool | None = False, ): """SaveFileAction allows saving data to a file. @@ -112,9 +115,13 @@ class SaveFileAction(BaseAction): create_dirs (bool): Whether to create parent directories if they do not exist. inject_last_result (bool): Whether to inject result from previous action. inject_into (str): Kwarg name to inject the last result as. + never_prompt (bool | None): Whether to never prompt for input. """ super().__init__( - name=name, inject_last_result=inject_last_result, inject_into=inject_into + name=name, + inject_last_result=inject_last_result, + inject_into=inject_into, + never_prompt=never_prompt, ) self._file_path = self._coerce_file_path(file_path) self._file_type = FileType(file_type) @@ -291,3 +298,19 @@ class SaveFileAction(BaseAction): def __str__(self) -> str: return f"SaveFileAction(file_path={self.file_path}, file_type={self.file_type})" + + def clone(self) -> SaveFileAction: + """Create a copy of this SaveFileAction with the same configuration.""" + return SaveFileAction( + name=self.name, + file_path=self.file_path, + file_type=self.file_type, + mode=self.mode, + encoding=self.encoding, + data=self.data, + overwrite=self.overwrite, + create_dirs=self.create_dirs, + inject_last_result=self.inject_last_result, + inject_into=self.inject_into, + never_prompt=self.local_never_prompt, + ) diff --git a/falyx/action/select_file_action.py b/falyx/action/select_file_action.py index 32730e1..6b76639 100644 --- a/falyx/action/select_file_action.py +++ b/falyx/action/select_file_action.py @@ -113,8 +113,9 @@ class SelectFileAction(BaseAction): separator: str = ",", allow_duplicates: bool = False, prompt_session: PromptSession | None = None, + never_prompt: bool | None = False, ): - super().__init__(name) + super().__init__(name, never_prompt=never_prompt) self.directory = Path(directory).resolve() self.title = title self.columns = columns @@ -183,6 +184,9 @@ class SelectFileAction(BaseAction): raise ValueError(f"Unsupported return type: {self.return_type}") except Exception as error: logger.error("Failed to parse %s: %s", file.name, error) + raise ValueError( + f"Failed to parse {file.name} as {self.return_type}: {error}" + ) from error return value def _find_cancel_key(self, options) -> str: @@ -290,3 +294,22 @@ class SelectFileAction(BaseAction): f"SelectFileAction(name={self.name!r}, dir={str(self.directory)!r}, " f"suffix_filter={self.suffix_filter!r}, return_type={self.return_type})" ) + + def clone(self) -> SelectFileAction: + """Create a copy of this SelectFileAction with the same configuration.""" + return SelectFileAction( + name=self.name, + directory=self.directory, + title=self.title, + columns=self.columns, + prompt_message=self.prompt_message, + style=self.style, + suffix_filter=self.suffix_filter, + return_type=self.return_type, + encoding=self.encoding, + number_selections=self.number_selections, + separator=self.separator, + allow_duplicates=self.allow_duplicates, + prompt_session=self.prompt_session, + never_prompt=self.local_never_prompt, + ) diff --git a/falyx/action/selection_action.py b/falyx/action/selection_action.py index b7e06dc..ec65385 100644 --- a/falyx/action/selection_action.py +++ b/falyx/action/selection_action.py @@ -30,6 +30,8 @@ Example: This module is foundational to creating expressive, user-centered CLI experiences within Falyx while preserving reproducibility and automation friendliness. """ +from __future__ import annotations + from typing import Any from prompt_toolkit import PromptSession @@ -138,7 +140,7 @@ class SelectionAction(BaseAction): inject_into: str = "last_result", return_type: SelectionReturnType | str = "value", prompt_session: PromptSession | None = None, - never_prompt: bool = False, + never_prompt: bool | None = False, show_table: bool = True, ): super().__init__( @@ -556,3 +558,23 @@ class SelectionAction(BaseAction): f"return_type={self.return_type!r}, " f"prompt={'off' if self.never_prompt else 'on'})" ) + + def clone(self) -> SelectionAction: + """Create a copy of this SelectionAction with the same configuration.""" + return SelectionAction( + name=self.name, + selections=self.selections.copy(), + title=self.title, + columns=self.columns, + prompt_message=self.prompt_message, + default_selection=self.default_selection, + number_selections=self.number_selections, + separator=self.separator, + allow_duplicates=self.allow_duplicates, + inject_last_result=self.inject_last_result, + inject_into=self.inject_into, + return_type=self.return_type, + prompt_session=self.prompt_session, + never_prompt=self.local_never_prompt, + show_table=self.show_table, + ) diff --git a/falyx/action/shell_action.py b/falyx/action/shell_action.py index 35797a8..66eaac2 100644 --- a/falyx/action/shell_action.py +++ b/falyx/action/shell_action.py @@ -101,3 +101,15 @@ class ShellAction(BaseIOAction): f"ShellAction(name={self.name!r}, command_template={self.command_template!r}," f" safe_mode={self.safe_mode})" ) + + def clone(self) -> ShellAction: + """Create a copy of this ShellAction with the same configuration.""" + return ShellAction( + name=self.name, + command_template=self.command_template, + safe_mode=self.safe_mode, + mode=self.mode, + hooks=self.hooks.copy(), + inject_last_result=self.inject_last_result, + inject_into=self.inject_into, + ) diff --git a/falyx/action/signal_action.py b/falyx/action/signal_action.py index 9c27f96..3e5d3cf 100644 --- a/falyx/action/signal_action.py +++ b/falyx/action/signal_action.py @@ -23,6 +23,8 @@ Use Cases: Example: SignalAction("ExitApp", QuitSignal(), hooks=my_hook_manager) """ +from __future__ import annotations + from rich.tree import Tree from falyx.action.action import Action @@ -90,3 +92,7 @@ class SignalAction(Action): tree = parent.add(label) if parent else Tree(label) if not parent: self.console.print(tree) + + def clone(self) -> SignalAction: + """Creates a copy of this SignalAction with the same configuration.""" + return SignalAction(name=self.name, signal=self.signal, hooks=self.hooks.copy()) diff --git a/falyx/action/user_input_action.py b/falyx/action/user_input_action.py index da5d4f4..fc7ac10 100644 --- a/falyx/action/user_input_action.py +++ b/falyx/action/user_input_action.py @@ -25,6 +25,8 @@ Example: validator=Validator.from_callable(lambda s: len(s) > 0), ) """ +from __future__ import annotations + from prompt_toolkit import PromptSession from prompt_toolkit.validation import Validator from rich.tree import Tree @@ -132,3 +134,15 @@ class UserInputAction(BaseAction): def __str__(self): return f"UserInputAction(name={self.name!r}, prompt={self.prompt!r})" + + def clone(self) -> UserInputAction: + """Creates a copy of this UserInputAction with the same configuration.""" + return UserInputAction( + name=self.name, + prompt_message=self.prompt_message, + default_text=self.default_text, + multiline=self.multiline, + validator=self.validator, + prompt_session=self.prompt_session, + inject_last_result=self.inject_last_result, + ) diff --git a/falyx/command.py b/falyx/command.py index ef57ce7..adf141e 100644 --- a/falyx/command.py +++ b/falyx/command.py @@ -191,7 +191,7 @@ class Command(BaseModel): spinner_type: str = "dots" spinner_style: Style | str = OneColors.CYAN spinner_speed: float = 1.0 - hooks: "HookManager" = Field(default_factory=HookManager) + hooks: HookManager = Field(default_factory=HookManager) retry: bool = False retry_all: bool = False retry_policy: RetryPolicy = Field(default_factory=RetryPolicy) @@ -307,6 +307,7 @@ class Command(BaseModel): @field_validator("action", mode="before") @classmethod def _wrap_callable_as_async(cls, action: Any) -> Any: + """Ensure the action is an async callable or a BaseAction instance.""" if isinstance(action, BaseAction): return action elif callable(action): @@ -314,6 +315,7 @@ class Command(BaseModel): raise TypeError("Action must be a callable or an instance of BaseAction") def _get_argument_definitions(self) -> list[dict[str, Any]]: + """Retrieve the argument definitions for the command.""" if self.arguments: return self.arguments elif callable(self.argument_config) and isinstance( @@ -382,6 +384,22 @@ class Command(BaseModel): if isinstance(self.action, BaseAction): self.action.set_options_manager(self.options_manager) + async def _handle_prompt_user(self) -> None: + """Handle user confirmation prompts based on command configuration and options.""" + action_never_prompt = None + if isinstance(self.action, BaseAction): + action_never_prompt = self.action.local_never_prompt + if should_prompt_user( + confirm=self.confirm, + options=self.options_manager, + action_never_prompt=action_never_prompt, + ): + if self.preview_before_confirm: + await self.preview() + if not await confirm_async(self._confirmation_prompt): + logger.info("[Command:%s] Cancelled by user.", self.key) + raise CancelSignal(f"[Command:{self.key}] Cancelled by confirmation.") + async def __call__(self, *args, **kwargs) -> Any: """Execute the command's underlying action with lifecycle management. @@ -430,12 +448,7 @@ class Command(BaseModel): ) self._context = context - if should_prompt_user(confirm=self.confirm, options=self.options_manager): - if self.preview_before_confirm: - await self.preview() - if not await confirm_async(self._confirmation_prompt): - logger.info("[Command:%s] Cancelled by user.", self.key) - raise CancelSignal(f"[Command:{self.key}] Cancelled by confirmation.") + await self._handle_prompt_user() context.start_timer() @@ -486,6 +499,18 @@ class Command(BaseModel): return FormattedText(prompt) + def get_option( + self, + option_name: str, + default: Any = None, + *, + namespace_name: str = "default", + ) -> Any: + """Resolve an option from the OptionsManager if present, else default.""" + if self.options_manager: + return self.options_manager.get(option_name, default, namespace_name) + return default + @property def primary_alias(self) -> str: """Get the primary alias for the command, used in help displays.""" @@ -557,6 +582,7 @@ class Command(BaseModel): ) def log_summary(self) -> None: + """Log a summary of the command execution if context is available.""" if self._context: self._context.log_summary() @@ -597,6 +623,7 @@ class Command(BaseModel): return False async def preview(self) -> None: + """Preview the command execution.""" label = f"[{OneColors.GREEN_b}]Command:[/] '{self.key}' — {self.description}" if hasattr(self.action, "preview") and callable(self.action.preview): @@ -855,3 +882,131 @@ class Command(BaseModel): command.hooks.register(HookType.ON_TEARDOWN, spinner_teardown_hook) return command + + def clone_with_overrides( + self, + *, + key: str | None = None, + description: str | None = None, + action: BaseAction | Callable[..., Any] | None = None, + args: tuple | None = None, + kwargs: dict[str, Any] | None = None, + hidden: bool | None = None, + aliases: list[str] | None = None, + help_text: str | None = None, + help_epilog: str | None = None, + style: Style | str | None = None, + confirm: bool | None = None, + confirm_message: str | None = None, + preview_before_confirm: bool | None = None, + spinner: bool | None = None, + spinner_message: str | None = None, + spinner_type: str | None = None, + spinner_style: Style | str | None = None, + spinner_speed: float | None = None, + hooks: HookManager | None = None, + retry: bool | None = None, + retry_all: bool | None = None, + retry_policy: RetryPolicy | None = None, + tags: list[str] | None = None, + logging_hooks: bool | None = None, + options_manager: OptionsManager | None = None, + arg_parser: CommandArgumentParser | None = None, + execution_options: list[ExecutionOption | str] | None = None, + arguments: list[dict[str, Any]] | None = None, + argument_config: Callable[[CommandArgumentParser], None] | None = None, + custom_parser: ArgParserProtocol | None = None, + custom_help: Callable[[], str | None] | None = None, + custom_tldr: Callable[[], str | None] | None = None, + custom_usage: Callable[[], str | None] | None = None, + auto_args: bool | None = None, + arg_metadata: dict[str, str | dict[str, Any]] | None = None, + simple_help_signature: bool | None = None, + ignore_in_history: bool | None = None, + program: str | None = None, + ) -> Command: + """Create a clone of the command with specified overrides.""" + if not arg_parser and self.arg_parser: + arg_parser = self.arg_parser.clone_with_overrides( + command_key=key or self.key, + command_description=description or self.description, + command_style=style or self.style, + help_text=help_text or self.help_text, + help_epilog=help_epilog or self.help_epilog, + aliases=aliases if aliases is not None else self.aliases, + program=program or self.program, + options_manager=options_manager or self.options_manager, + ) + if not hooks and self.hooks: + hooks = self.hooks.copy() + if not action and self.action: + if isinstance(self.action, BaseAction): + cloned_action: ( + BaseAction | Callable[..., Any] | Callable[..., Awaitable[Any]] + ) = self.action.clone() + elif callable(self.action): + cloned_action = self.action + else: + raise NotAFalyxError("Action must be a BaseAction or callable to clone.") + return Command.build( + key=key or self.key, + description=description or self.description, + action=action or cloned_action, + args=args if args is not None else self.args, + kwargs=kwargs if kwargs is not None else self.kwargs, + hidden=hidden if hidden is not None else self.hidden, + aliases=aliases if aliases is not None else self.aliases, + help_text=help_text if help_text is not None else self.help_text, + help_epilog=help_epilog if help_epilog is not None else self.help_epilog, + style=style or self.style, + confirm=confirm if confirm is not None else self.confirm, + confirm_message=confirm_message or self.confirm_message, + preview_before_confirm=( + preview_before_confirm + if preview_before_confirm is not None + else self.preview_before_confirm + ), + spinner=spinner if spinner is not None else self.spinner, + spinner_message=spinner_message or self.spinner_message, + spinner_type=spinner_type or self.spinner_type, + spinner_style=spinner_style or self.spinner_style, + spinner_speed=( + spinner_speed if spinner_speed is not None else self.spinner_speed + ), + hooks=hooks or self.hooks, + retry=retry if retry is not None else self.retry, + retry_all=retry_all if retry_all is not None else self.retry_all, + retry_policy=retry_policy or self.retry_policy, + tags=tags if tags is not None else self.tags, + logging_hooks=( + logging_hooks if logging_hooks is not None else self.logging_hooks + ), + options_manager=options_manager or self.options_manager, + arg_parser=arg_parser or self.arg_parser, + execution_options=( + execution_options + if execution_options is not None + else (list(self.execution_options) if self.execution_options else []) + ), + arguments=arguments if arguments is not None else (self.arguments or []), + argument_config=argument_config or self.argument_config, + custom_parser=custom_parser or self.custom_parser, + custom_help=custom_help or self.custom_help, + custom_tldr=custom_tldr or self.custom_tldr, + custom_usage=custom_usage or self.custom_usage, + auto_args=auto_args if auto_args is not None else self.auto_args, + arg_metadata=( + arg_metadata if arg_metadata is not None else (self.arg_metadata or {}) + ), + simple_help_signature=( + simple_help_signature + if simple_help_signature is not None + else self.simple_help_signature + ), + ignore_in_history=( + ignore_in_history + if ignore_in_history is not None + else self.ignore_in_history + ), + program=program or self.program, + ) diff --git a/falyx/command_executor.py b/falyx/command_executor.py index b512d24..2037eb2 100644 --- a/falyx/command_executor.py +++ b/falyx/command_executor.py @@ -82,7 +82,7 @@ class CommandExecutor: - Emit optional execution summaries Attributes: - options (OptionsManager): Shared options manager used to apply scoped + options_manager (OptionsManager): Shared options manager used to apply scoped execution overrides. hooks (HookManager): Hook manager for executor-level lifecycle hooks. """ @@ -90,10 +90,10 @@ class CommandExecutor: def __init__( self, *, - options: OptionsManager, + options_manager: OptionsManager, hooks: HookManager, ) -> None: - self.options = options + self.options_manager = options_manager self.hooks = hooks def _debug_hooks(self, command: Command) -> None: @@ -271,7 +271,7 @@ class CommandExecutor: try: await self.hooks.trigger(HookType.BEFORE, context) - with self.options.override_namespace( + with self.options_manager.override_namespace( overrides=overrides, namespace_name="execution", ): diff --git a/falyx/command_runner.py b/falyx/command_runner.py index 2e60f0a..0948021 100644 --- a/falyx/command_runner.py +++ b/falyx/command_runner.py @@ -92,7 +92,7 @@ class CommandRunner: Attributes: command (Command): The command executed by this runner. program (str): Program name used in CLI usage text and help output. - options (OptionsManager): Shared options manager used by the command, + options_manager (OptionsManager): Shared options manager used by the command, parser, and executor. runner_hooks (HookManager): Executor-level hooks used during execution. console (Console): Rich console used for user-facing output. @@ -105,7 +105,7 @@ class CommandRunner: command: Command, *, program: str | None = None, - options: OptionsManager | None = None, + options_manager: OptionsManager | None = None, runner_hooks: HookManager | None = None, console: Console | None = None, ) -> None: @@ -120,7 +120,7 @@ class CommandRunner: program (str | None): Program name used in CLI usage text, invocation-path rendering, and built-in help output. If `None`, an empty program name is used. - options (OptionsManager | None): Optional shared options manager. If + options_manager (OptionsManager | None): Optional shared options manager. If omitted, a new `OptionsManager` is created. runner_hooks (HookManager | None): Optional executor-level hook manager. If omitted, a new `HookManager` is created. @@ -129,23 +129,26 @@ class CommandRunner: """ self.command = command self.program = program or "" - self.options = self._get_options(options) + self.options_manager = self._get_options_manager(options_manager) self.runner_hooks = self._get_hooks(runner_hooks) self.console = self._get_console(console) self.error_console = error_console - self.command.options_manager = self.options + self.command.options_manager = self.options_manager if program: self.command.program = program if isinstance(self.command.arg_parser, CommandArgumentParser): - self.command.arg_parser.set_options_manager(self.options) + self.command.arg_parser.set_options_manager(self.options_manager) self.command.arg_parser.is_runner_mode = True if program: self.command.arg_parser.program = program self.executor = CommandExecutor( - options=self.options, + options_manager=self.options_manager, hooks=self.runner_hooks, ) - self.options.from_mapping(values={}, namespace_name="execution") + if not self.options_manager.get_namespace("root"): + self.options_manager.from_mapping(values={}, namespace_name="root") + if not self.options_manager.get_namespace("execution"): + self.options_manager.from_mapping(values={}, namespace_name="execution") def _get_console(self, console) -> Console: if console is None: @@ -155,13 +158,18 @@ class CommandRunner: else: raise NotAFalyxError("console must be an instance of rich.Console or None.") - def _get_options(self, options) -> OptionsManager: - if options is None: + def _get_options_manager( + self, + options_manager: OptionsManager | None, + ) -> OptionsManager: + if options_manager is None: return OptionsManager() - elif isinstance(options, OptionsManager): - return options + elif isinstance(options_manager, OptionsManager): + return options_manager else: - raise NotAFalyxError("options must be an instance of OptionsManager or None.") + raise NotAFalyxError( + "options_manager must be an instance of OptionsManager or None." + ) def _get_hooks(self, hooks) -> HookManager: if hooks is None: @@ -295,7 +303,7 @@ class CommandRunner: *, program: str | None = None, runner_hooks: HookManager | None = None, - options: OptionsManager | None = None, + options_manager: OptionsManager | None = None, console: Console | None = None, ) -> CommandRunner: """Create a `CommandRunner` from an existing `Command` instance. @@ -311,7 +319,7 @@ class CommandRunner: used. runner_hooks (HookManager | None): Optional executor-level hook manager for the runner. - options (OptionsManager | None): Optional shared options manager. + options_manager (OptionsManager | None): Optional shared options manager. console (Console | None): Optional Rich console for output. Returns: @@ -325,10 +333,14 @@ class CommandRunner: raise NotAFalyxError("command must be an instance of Command.") if runner_hooks and not isinstance(runner_hooks, HookManager): raise InvalidHookError("runner_hooks must be an instance of HookManager.") - return cls( - command=command, + bound_command = command.clone_with_overrides( + options_manager=options_manager, program=program, - options=options, + ) + return cls( + command=bound_command, + program=program, + options_manager=options_manager, runner_hooks=runner_hooks, console=console, ) @@ -357,7 +369,7 @@ class CommandRunner: spinner_type: str = "dots", spinner_style: str = OneColors.CYAN, spinner_speed: float = 1.0, - options: OptionsManager | None = None, + options_manager: OptionsManager | None = None, command_hooks: HookManager | None = None, before_hooks: list[Callable] | None = None, success_hooks: list[Callable] | None = None, @@ -415,7 +427,7 @@ class CommandRunner: spinner_type (str): Spinner animation type. spinner_style (str): Spinner style. spinner_speed (float): Spinner speed multiplier. - options (OptionsManager | None): Shared options manager for the command + options_manager (OptionsManager | None): Shared options manager for the command and runner. command_hooks (HookManager | None): Optional hook manager for the built command itself. @@ -473,7 +485,7 @@ class CommandRunner: - Command construction is delegated to `Command.build()` so command configuration remains centralized. """ - options = options or OptionsManager() + options_manager = options_manager or OptionsManager() command = Command.build( key=key, description=description, @@ -499,7 +511,7 @@ class CommandRunner: retry=retry, retry_all=retry_all, retry_policy=retry_policy, - options_manager=options, + options_manager=options_manager, hooks=command_hooks, before_hooks=before_hooks, success_hooks=success_hooks, @@ -525,7 +537,7 @@ class CommandRunner: return cls( command=command, - options=options, + options_manager=options_manager, runner_hooks=runner_hooks, console=console, ) diff --git a/falyx/completer.py b/falyx/completer.py index 7852513..2d1e7af 100644 --- a/falyx/completer.py +++ b/falyx/completer.py @@ -83,7 +83,7 @@ class FalyxCompleter(Completer): - Detects preview-mode input prefixed with `?`. - Separates committed tokens from the active stub under the cursor. - Resolves the partial route through `Falyx.resolve_completion_route()`. - - Suggests namespace entries and namespace help flags while routing. + - Suggests namespace entries and namespace flags while routing. - Delegates leaf-command completion to `CommandArgumentParser.suggest_next()` once a command is resolved. - Preserves shell-safe quoting for suggestions containing spaces. @@ -137,15 +137,14 @@ class FalyxCompleter(Completer): # Still selecting an entry in the current namespace if route.expecting_entry: + namespace_suggestions, expecting_value = route.namespace.parser.suggest_next( + route.remaining_argv, route.cursor_at_end_of_token + ) + yield from self._yield_completions(namespace_suggestions, route.stub) + if expecting_value: + return suggestions = self._suggest_namespace_entries(route.namespace, route.stub) - # Only here should namespace-level help/TLDR be suggested. - # TODO: better completer in FalyxParser - if not route.command: # and (not route.stub or route.stub.startswith("-")): - for flag in route.namespace.parser._options_by_dest: - if flag.startswith(route.stub): - suggestions.append(flag) - if route.is_preview: suggestions = [f"?{s}" for s in suggestions] current_stub = f"?{route.stub}" if route.stub else "?" @@ -171,7 +170,7 @@ class FalyxCompleter(Completer): except Exception: return - yield from self._yield_lcp_completions(suggestions, route.stub) + yield from self._yield_completions(suggestions, route.stub) def _suggest_namespace_entries(self, namespace: Falyx, prefix: str) -> list[str]: """Return matching visible entry names for a namespace prefix. @@ -190,6 +189,7 @@ class FalyxCompleter(Completer): """ results: list[str] = [] for name in namespace.completion_names: + # results.append(name) if name.upper().startswith(prefix.upper()): results.append(name.lower() if prefix.islower() else name) return results @@ -207,7 +207,31 @@ class FalyxCompleter(Completer): return f'"{text}"' return text - def _yield_lcp_completions(self, suggestions, stub) -> Iterable[Completion]: + def _yield_completions( + self, + suggestions: list[str], + stub: str, + ) -> Iterable[Completion]: + """Yield Completion objects for a list of suggestion strings. + + This helper converts raw suggestion strings into Prompt Toolkit `Completion` + instances with appropriate insertion behavior. It assumes that the caller + has already determined the correct start position for insertion. + + Args: + suggestions (list[str]): Raw completion candidates to convert. + stub (str): The currently typed prefix (used to offset insertion). + """ + for suggestion in suggestions: + yield Completion( + self._ensure_quote(suggestion), + start_position=-len(stub), + display=suggestion, + ) + + def _yield_lcp_completions( + self, suggestions: list[str], stub: str + ) -> Iterable[Completion]: """Yield completions for the current stub using longest-common-prefix logic. Behavior: diff --git a/falyx/completer_types.py b/falyx/completer_types.py index cbe13a3..0bb04b7 100644 --- a/falyx/completer_types.py +++ b/falyx/completer_types.py @@ -59,6 +59,9 @@ class CompletionRoute: leaf_argv (list[str]): Remaining command-local argv tokens that belong to the resolved leaf command. These are typically passed to the command's argument parser for completion. + remaining_argv (list[str]): Remaining argv tokens that have not yet been + consumed by routing or command resolution. These are typically passed + to the next routing or parsing stage for further resolution. stub (str): The current token fragment under the cursor. This is the partial text that completion candidates should replace or extend. cursor_at_end_of_token (bool): Whether the cursor is positioned at the @@ -81,6 +84,7 @@ class CompletionRoute: context: InvocationContext command: Command | None = None leaf_argv: list[str] = field(default_factory=list) + remaining_argv: list[str] = field(default_factory=list) stub: str = "" cursor_at_end_of_token: bool = False expecting_entry: bool = False diff --git a/falyx/console.py b/falyx/console.py index ff66566..0e2cca7 100644 --- a/falyx/console.py +++ b/falyx/console.py @@ -2,6 +2,7 @@ """Global console instance for Falyx CLI applications.""" from rich.console import Console +from falyx.exceptions import FalyxError from falyx.themes import OneColors, get_nord_theme console = Console(color_system="truecolor", theme=get_nord_theme()) @@ -13,6 +14,9 @@ def print_error( *, hint: str | None = None, ) -> None: + if hint is None and isinstance(message, FalyxError): + hint = message.hint + error_console.print(f"[{OneColors.DARK_RED}]error:[/] {message}") if hint: error_console.print(f"[{OneColors.LIGHT_YELLOW}]hint:[/] {hint}") diff --git a/falyx/exceptions.py b/falyx/exceptions.py index 2ff1a8d..30c2175 100644 --- a/falyx/exceptions.py +++ b/falyx/exceptions.py @@ -209,12 +209,38 @@ class MissingValueError(ArgumentParsingError): def __init__( self, dest: str, - expected_count: int | None = None, + expected_count: int | str | None = None, actual_count: int | None = None, + display_name: str | None = None, + show_short_usage: bool = True, ): + self.dest = dest self.expected_count = expected_count self.actual_count = actual_count - self.dest = dest + self.display_name = display_name or dest + super().__init__( + self.build_message(), + self.build_hint(), + show_short_usage=show_short_usage, + dest=dest, + ) + + def build_message(self) -> str: + if self.expected_count is None or self.expected_count in (1, "+"): + return f"missing value for '{self.display_name}'" + + actual = 0 if self.actual_count is None else self.actual_count + return ( + f"missing values for '{self.display_name}': " + f"expected {self.expected_count}, got {actual}" + ) + + def build_hint(self) -> str | None: + if self.expected_count is None or self.expected_count == 1: + return f"provide a value for '{self.display_name}'." + elif self.expected_count == "+": + return f"provide one or more values for '{self.display_name}'." + return f"provide {self.expected_count} values for '{self.display_name}'." class TokenizationError(UsageError): diff --git a/falyx/execution_registry.py b/falyx/execution_registry.py index fe2f0de..01d1492 100644 --- a/falyx/execution_registry.py +++ b/falyx/execution_registry.py @@ -256,7 +256,7 @@ class ExecutionRegistry: if ctx.exception and status.lower() in ["all", "error"]: final_status = f"[{OneColors.DARK_RED}]❌ Error" final_result = repr(ctx.exception) - elif status.lower() in ["all", "success"]: + elif not ctx.exception and status.lower() in ["all", "success"]: final_status = f"[{OneColors.GREEN}]✅ Success" final_result = repr(ctx.result) if len(final_result) > 50: diff --git a/falyx/falyx.py b/falyx/falyx.py index 7c70791..98267f0 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -64,11 +64,12 @@ import asyncio import logging import shlex import sys +from collections.abc import Callable from difflib import get_close_matches from functools import cached_property from pathlib import Path from random import choice -from typing import Any, Callable +from typing import Any from prompt_toolkit import PromptSession from prompt_toolkit.application import get_app @@ -103,6 +104,7 @@ from falyx.exceptions import ( CommandArgumentError, EntryNotFoundError, FalyxError, + FalyxOptionError, InvalidActionError, InvalidHookError, NotAFalyxError, @@ -115,7 +117,8 @@ from falyx.logger import logger from falyx.mode import FalyxMode from falyx.namespace import FalyxNamespace from falyx.options_manager import OptionsManager -from falyx.parser import CommandArgumentParser, FalyxParser, ParseResult +from falyx.parser import CommandArgumentParser, FalyxParser, OptionAction +from falyx.parser.option import Option from falyx.parser.parser_types import FalyxTLDRInput from falyx.prompt_utils import rich_text_to_prompt_text from falyx.protocols import ArgParserProtocol @@ -128,7 +131,6 @@ from falyx.validators import CommandValidator from falyx.version import __version__ -# TODO: better OptionsManager determination (assert same instance across a namespace) class Falyx: """Primary controller for Falyx CLI applications. @@ -206,7 +208,7 @@ class Falyx: builtins (dict[str, Command]): Registered built-in commands such as help, preview, and version. namespaces (dict[str, FalyxNamespace]): Registered nested namespaces. - options (OptionsManager): Shared runtime option manager. + options_manager (OptionsManager): Shared runtime option manager. hooks (HookManager): Application-level hook manager. console (Console): Rich console used for rendering output. key_bindings (KeyBindings): Prompt Toolkit key bindings for menu mode. @@ -254,7 +256,7 @@ class Falyx: force_confirm: bool = False, verbose: bool = False, debug_hooks: bool = False, - options: OptionsManager | None = None, + options_manager: OptionsManager | None = None, render_menu: Callable[[Falyx], None] | None = None, custom_table: Callable[[Falyx], Table] | Table | None = None, hide_menu_table: bool = False, @@ -328,7 +330,7 @@ class Falyx: runtime option. verbose (bool): Default session-level value for the `verbose` runtime option. debug_hooks (bool): Default session-level value for the `debug_hooks` runtime option. - options (OptionsManager | None): Shared options manager for the application. + options_manager (OptionsManager | None): Shared options manager for the application. If omitted, a new `OptionsManager` instance is created. render_menu (Callable[[Falyx], None] | None): Optional custom menu renderer used instead of the default table-based menu output. @@ -355,7 +357,7 @@ class Falyx: option from the root parser. Raises: - FalyxError: If the provided options object is invalid or other core runtime + FalyxError: If the provided options_manager object is invalid or other core runtime configuration is inconsistent. Notes: @@ -399,9 +401,9 @@ class Falyx: self.custom_table: Callable[[Falyx], Table] | Table | None = custom_table self._hide_menu_table: bool = hide_menu_table self.show_placeholder_menu: bool = show_placeholder_menu - self._validate_options(options) + self._validate_options_manager(options_manager) self._prompt_session: PromptSession | None = None - self.options.set("mode", FalyxMode.COMMAND) + self.options_manager.set("mode", FalyxMode.COMMAND) self.exit_command: Command = self._get_exit_command() self.history_command: Command | None = ( self._get_history_command() if include_history_command else None @@ -419,9 +421,9 @@ class Falyx: self.default_to_menu: bool = default_to_menu self.simple_usage: bool = simple_usage self._register_default_builtins() - self._register_options() + self._register_runtime_options() self._executor = CommandExecutor( - options=self.options, + options_manager=self.options_manager, hooks=self.hooks, ) self.disable_verbose_option: bool = disable_verbose_option @@ -429,6 +431,38 @@ class Falyx: self.disable_never_prompt_option: bool = disable_never_prompt_option self.parser: FalyxParser = FalyxParser(self) + def __str__(self) -> str: + return ( + f"Falyx(program='{self.program}', " + f"title='{self.title}', " + f"description='{self.description}')" + ) + + def __repr__(self) -> str: + return self.__str__() + + def add_option( + self, + *flags: str, + action: str | OptionAction = "store", + default: Any = None, + type: Callable[[Any], Any] = str, + choices: list[str] | None = None, + help: str = "", + dest: str | None = None, + suggestions: list[str] | None = None, + ) -> Option: + return self.parser.add_option( + *flags, + action=action, + default=default, + type=type, + choices=choices, + help=help, + dest=dest, + suggestions=suggestions, + ) + def add_tldr_example( self, *, @@ -487,7 +521,7 @@ class Falyx: program=self.program, program_style=self.program_style, typed_path=[], - mode=self.options.get("mode"), + mode=self.options_manager.get("mode"), ) @property @@ -497,11 +531,11 @@ class Falyx: Returns: bool: `True` when the active mode is not `FalyxMode.MENU`. """ - return self.options.get("mode") != FalyxMode.MENU + return self.options_manager.get("mode") != FalyxMode.MENU - def _validate_options( + def _validate_options_manager( self, - options: OptionsManager | None = None, + options_manager: OptionsManager | None = None, ) -> None: """Validate and install the shared options manager. @@ -509,44 +543,47 @@ class Falyx: stored on the instance. Args: - options (OptionsManager | None): Optional options manager to reuse. + options_manager (OptionsManager | None): Optional options manager to reuse. Raises: - NotAFalyxError: If `options` is provided but is not an `OptionsManager`. + NotAFalyxError: If `options_manager` is provided but is not an `OptionsManager`. """ - self.options: OptionsManager = options or OptionsManager() - if not isinstance(self.options, OptionsManager): - raise NotAFalyxError("options must be an instance of OptionsManager.") + self.options_manager: OptionsManager = options_manager or OptionsManager() + if not isinstance(self.options_manager, OptionsManager): + raise NotAFalyxError("options_manager must be an instance of OptionsManager.") - def _register_options(self) -> None: + def _register_runtime_options(self) -> None: """Seed default application options and execution namespace values. - This method ensures that core runtime flags such as mode, prompt behavior, + This method ensures that core runtime flags such as prompt behavior, menu visibility, and program display metadata exist in the shared options manager. """ - self.options.from_mapping(values={}, namespace_name="execution") + if not self.options_manager.get_namespace("root"): + self.options_manager.from_mapping(values={}, namespace_name="root") + if not self.options_manager.get_namespace("execution"): + self.options_manager.from_mapping(values={}, namespace_name="execution") - if not self.options.get("never_prompt"): - self.options.set("never_prompt", self._never_prompt) + if not self.options_manager.has_option("never_prompt"): + self.options_manager.set("never_prompt", self._never_prompt, "root") - if not self.options.get("force_confirm"): - self.options.set("force_confirm", self._force_confirm) + if not self.options_manager.has_option("force_confirm"): + self.options_manager.set("force_confirm", self._force_confirm, "root") - if not self.options.get("verbose"): - self.options.set("verbose", self._verbose) + if not self.options_manager.has_option("verbose"): + self.options_manager.set("verbose", self._verbose, "root") - if not self.options.get("debug_hooks"): - self.options.set("debug_hooks", self._debug_hooks) + if not self.options_manager.has_option("debug_hooks"): + self.options_manager.set("debug_hooks", self._debug_hooks, "root") - if not self.options.get("hide_menu_table"): - self.options.set("hide_menu_table", self._hide_menu_table) + if not self.options_manager.has_option("hide_menu_table"): + self.options_manager.set("hide_menu_table", self._hide_menu_table) - if not self.options.get("program"): - self.options.set("program", self.program) + if not self.options_manager.has_option("program"): + self.options_manager.set("program", self.program) - if not self.options.get("program_style"): - self.options.set("program_style", self.program_style) + if not self.options_manager.has_option("program_style"): + self.options_manager.set("program_style", self.program_style) @property def completion_names(self) -> list[str]: @@ -670,7 +707,7 @@ class Falyx: style=OneColors.DARK_RED, simple_help_signature=True, ignore_in_history=True, - options_manager=self.options, + options_manager=self.options_manager, program=self.program, help_text="Exit the program.", ) @@ -744,7 +781,7 @@ class Falyx: argument_config=add_history_arguments, help_text="View the execution history of commands.", ignore_in_history=True, - options_manager=self.options, + options_manager=self.options_manager, program=self.program, ) @@ -1313,7 +1350,7 @@ class Falyx: style=OneColors.LIGHT_YELLOW, argument_config=add_help_arguments, ignore_in_history=True, - options_manager=self.options, + options_manager=self.options_manager, program=self.program, ) @@ -1364,7 +1401,7 @@ class Falyx: aliases=["PREVIEW"], action=Action("Preview", self._preview), style=OneColors.GREEN, - options_manager=self.options, + options_manager=self.options_manager, program=self.program, help_text="Preview the execution of a command without running it.", argument_config=add_preview_argument, @@ -1388,7 +1425,7 @@ class Falyx: action=Action("Version", self._render_version), style=self.version_style, ignore_in_history=True, - options_manager=self.options, + options_manager=self.options_manager, program=self.program, help_text=f"Show the {self.program} version.", ) @@ -1664,7 +1701,7 @@ class Falyx: confirm=confirm, confirm_message=confirm_message, ignore_in_history=True, - options_manager=self.options, + options_manager=self.options_manager, program=self.program, help_text=help_text, ) @@ -1726,41 +1763,56 @@ class Falyx: help_text="Go back to the previous menu.", ) - def add_commands(self, commands: list[Command] | list[dict]) -> None: + def add_commands(self, commands: list[Command] | list[dict]) -> list[Command]: """Register multiple commands from instances or config dictionaries. Args: commands (list[Command] | list[dict]): Sequence of `Command` objects or `add_command()` keyword dictionaries. + Returns: + list[Command]: List of registered `Command` instances. + Raises: FalyxError: If an element is neither a `Command` nor a configuration dictionary. """ + added_commands = [] for command in commands: if isinstance(command, dict): - self.add_command(**command) + added_commands.append(self.add_command(**command)) elif isinstance(command, Command): - self.add_command_from_command(command) + added_commands.append(self.add_command_from_command(command)) else: raise FalyxError( "command must be a dictionary or an instance of Command." ) + return added_commands - def add_command_from_command(self, command: Command) -> None: - """Register an already-built `Command` object. + def add_command_from_command(self, command: Command) -> Command: + """Registers a clone of the provided command and returns the bound clone + owned by this namespace. Args: command (Command): Preconstructed command to add to this namespace. + Returns: + Command: The newly registered clone of the command instance bound to + this namespace. + Raises: FalyxError: If `command` is not a `Command`. """ if not isinstance(command, Command): raise FalyxError("command must be an instance of Command.") self._validate_command_aliases(command.key, command.aliases) - self.commands[command.key] = command + bound_command = command.clone_with_overrides( + options_manager=self.options_manager, + program=self.program, + ) + self.commands[command.key] = bound_command _ = self._entry_map + return bound_command def add_command( self, @@ -1903,7 +1955,7 @@ class Falyx: auto_args=auto_args, arg_metadata=arg_metadata, simple_help_signature=simple_help_signature, - options_manager=self.options, + options_manager=self.options_manager, ignore_in_history=ignore_in_history, program=self.program, ) @@ -2146,7 +2198,7 @@ class Falyx: program=self.program, program_style=self.program_style, typed_path=[], - mode=mode or self.options.get("mode"), + mode=mode or self.options_manager.get("mode"), is_preview=is_preview, ) @@ -2253,6 +2305,7 @@ class Falyx: EOFError: If execution receives an unexpected end of input and `wrap_errors` is `False`. """ + route.namespace._apply_root_options() if route.is_preview: if route.kind is RouteKind.COMMAND and route.command: logger.info("preview command '%s' selected.", route.command.key) @@ -2286,6 +2339,10 @@ class Falyx: if command is route.namespace.help_command: kwargs = kwargs or {} + # pop the help command key from the typed path to avoid it being + # treated as a real argument during help rendering + route.context.typed_path.pop() + route.context.segments.pop() kwargs["invocation_context"] = route.context logger.debug( @@ -2295,15 +2352,22 @@ class Falyx: kwargs, execution_args, ) - return await self._executor.execute( - command=route.command, - args=args, - kwargs=kwargs or {}, - execution_args=execution_args or {}, - raise_on_error=raise_on_error, - wrap_errors=wrap_errors, - summary_last_result=summary_last_result, + route.namespace.options_manager.seed_missing( + route.namespace_defaults, ) + with route.namespace.options_manager.override_namespace( + route.namespace_overrides, + "default", + ): + return await self._executor.execute( + command=route.command, + args=args, + kwargs=kwargs or {}, + execution_args=execution_args or {}, + raise_on_error=raise_on_error, + wrap_errors=wrap_errors, + summary_last_result=summary_last_result, + ) async def execute_command( self, @@ -2372,15 +2436,23 @@ class Falyx: assert route is not None, "prepare_route should never return None." - return await self._dispatch_route( - route=route, - args=args, - kwargs=kwargs, - execution_args=execution_args, - raise_on_error=raise_on_error, - wrap_errors=wrap_errors, - summary_last_result=summary_last_result, + route.namespace.options_manager.seed_missing( + route.root_defaults, + namespace_name="root", ) + with route.namespace.options_manager.override_namespace( + route.root_overrides, + namespace_name="root", + ): + return await self._dispatch_route( + route=route, + args=args, + kwargs=kwargs, + execution_args=execution_args, + raise_on_error=raise_on_error, + wrap_errors=wrap_errors, + summary_last_result=summary_last_result, + ) def resolve_completion_route( self, @@ -2409,21 +2481,57 @@ class Falyx: """ namespace = self route_context = invocation_context - remaining = list(committed_tokens) + remaining_in_namespace = [stub] + remaining = list(committed_tokens) while remaining: + remaining = list(remaining) + remaining_in_namespace = list(remaining) + ([stub] if stub else []) + try: + parse_result = namespace.parser.parse_args(remaining) + except FalyxOptionError: + # If committed tokens end with a namespace-level option, the completer should + # suggest values for that option instead of namespace entries. + return CompletionRoute( + namespace=namespace, + context=route_context, + command=None, + remaining_argv=remaining_in_namespace, + stub=stub, + cursor_at_end_of_token=cursor_at_end_of_token, + expecting_entry=True, + is_preview=is_preview, + ) + + remaining = list(parse_result.remaining_argv) + remaining_in_namespace = list(remaining) + ([stub] if stub else []) + + if not remaining: + break + head = remaining.pop(0) entry, _ = namespace.resolve_entry(head) if entry is None: + if remaining or stub: + return CompletionRoute( + namespace=namespace, + context=route_context, + command=None, + remaining_argv=remaining_in_namespace, + stub="", + cursor_at_end_of_token=cursor_at_end_of_token, + expecting_entry=False, + is_preview=is_preview, + ) # Still routing namespace entries; could not resolve this token. # Let the completer suggest entries or namespace-level flags. return CompletionRoute( namespace=namespace, context=route_context, command=None, - leaf_argv=[], - stub=head if not remaining else stub, + remaining_argv=remaining_in_namespace, + stub=head, cursor_at_end_of_token=cursor_at_end_of_token, expecting_entry=True, is_preview=is_preview, @@ -2453,6 +2561,7 @@ class Falyx: context=route_context, command=None, leaf_argv=[], + remaining_argv=remaining_in_namespace, stub=stub, cursor_at_end_of_token=cursor_at_end_of_token, expecting_entry=True, @@ -2465,6 +2574,8 @@ class Falyx: *, invocation_context: InvocationContext, is_preview: bool = False, + root_defaults: dict[str, Any] | None = None, + root_overrides: dict[str, Any] | None = None, ) -> RouteResult: """Resolve an invocation path across namespaces until a leaf boundary. @@ -2485,7 +2596,13 @@ class Falyx: """ # 1. Namespace-level parsing for help/tldr flags and root/session options parse_result = self.parser.parse_args(tokens) - self.parser.apply_to_options(parse_result, self.options) + if not root_defaults: + root_defaults = {} + if not root_overrides: + root_overrides = {} + parse_result.root_defaults = root_defaults | parse_result.root_defaults + parse_result.root_options = root_overrides | parse_result.root_options + tokens = parse_result.remaining_argv # 2. Help or TLDR requested for this namespace @@ -2507,12 +2624,22 @@ class Falyx: ) # 3. No more tokens -> this namespace itself was targeted - if not tokens: + if not tokens and (parse_result.namespace_options or parse_result.root_options): + return RouteResult( + kind=RouteKind.UNKNOWN, + namespace=self, + context=invocation_context, + current_head=parse_result.current_head, + is_preview=is_preview, + ) + elif not tokens: return RouteResult( kind=RouteKind.NAMESPACE_MENU, namespace=self, context=invocation_context, is_preview=is_preview, + root_defaults=parse_result.root_defaults, + root_overrides=parse_result.root_options, ) head, *tail = tokens @@ -2534,7 +2661,11 @@ class Falyx: # 5. Namespace entry -> recurse with remaining tokens if isinstance(entry, FalyxNamespace): return await entry.namespace.resolve_route( - tail, invocation_context=route_context, is_preview=is_preview + tail, + invocation_context=route_context, + is_preview=is_preview, + root_defaults=parse_result.root_defaults, + root_overrides=parse_result.root_options, ) # 6. Leaf command -> stop routing; leave tail untouched for leaf parser @@ -2546,6 +2677,10 @@ class Falyx: leaf_argv=tail, current_head=head, is_preview=is_preview, + root_defaults=parse_result.root_defaults, + root_overrides=parse_result.root_options, + namespace_defaults=parse_result.namespace_defaults, + namespace_overrides=parse_result.namespace_options, ) async def _process_command(self) -> None: @@ -2577,12 +2712,12 @@ class Falyx: welcome and exit messages. """ logger.info("Starting menu: %s", self.title) - self.options.set("mode", FalyxMode.MENU) + self.options_manager.set("mode", FalyxMode.MENU) if self.welcome_message: self.console.print(self.welcome_message) try: while True: - if not self.options.get("hide_menu_table", self._hide_menu_table): + if not self.options_manager.get("hide_menu_table", self._hide_menu_table): if callable(self.render_menu): self.render_menu(self) else: @@ -2608,32 +2743,20 @@ class Falyx: if self.exit_message: self.console.print(self.exit_message) - def _apply_parse_result(self, result: ParseResult) -> None: + def _apply_root_options(self) -> None: """Apply parsed root/session options to runtime state. - This updates the active mode, logging verbosity, debug-hook registration, - and prompt behavior based on the root parse result. - - Args: - result (ParseResult): Parsed root CLI result to apply. + This updates logging verbosity and debug-hook registration. """ - self.options.set("mode", result.mode) - - if result.verbose: - logging.getLogger("falyx").setLevel(logging.DEBUG) - self.options.set("verbose", True) + falyx_logger = logging.getLogger("falyx") + if self.options_manager.get("verbose", False, "root"): + falyx_logger.setLevel(logging.DEBUG) else: - self.options.set("verbose", False) + falyx_logger.setLevel(logging.WARNING) - if result.debug_hooks: - self.options.set("debug_hooks", True) + if self.options_manager.get("debug_hooks", False, "root"): self.register_all_with_debug_hooks() logger.debug("Enabling global debug hooks for all commands") - else: - self.options.set("debug_hooks", False) - - if result.never_prompt: - self.options.set("never_prompt", True) async def run(self, always_start_menu: bool = False) -> None: """Execute the Falyx application using CLI-driven dispatch. @@ -2690,21 +2813,29 @@ class Falyx: assert route is not None, "prepare_route should never return None." try: - await self._dispatch_route( - route=route, - args=args, - kwargs=kwargs, - execution_args=execution_args, - raise_on_error=False, - wrap_errors=True, + route.namespace.options_manager.seed_missing( + route.root_defaults, + namespace_name="root", ) + with route.namespace.options_manager.override_namespace( + route.root_overrides, + namespace_name="root", + ): + await self._dispatch_route( + route=route, + args=args, + kwargs=kwargs, + execution_args=execution_args, + raise_on_error=False, + wrap_errors=True, + ) except EntryNotFoundError as error: await self.render_help() print_error(message=error) sys.exit(2) except (FalyxError, Exception) as error: print_error(message=error) - if self.options.get("verbose"): + if self.options_manager.get("verbose", False, "root"): logger.error("Error: %s", error, exc_info=True) sys.exit(1) except QuitSignal: diff --git a/falyx/hook_manager.py b/falyx/hook_manager.py index 33f7328..ec076c2 100644 --- a/falyx/hook_manager.py +++ b/falyx/hook_manager.py @@ -179,3 +179,10 @@ class HookManager: hook_list = self._hooks.get(hook_type, []) lines.append(f" {hook_type.value}: {format_hook_list(hook_list)}") return "\n".join(lines) + + def copy(self) -> HookManager: + """Create a deep copy of this HookManager, including all registered hooks.""" + new_manager = HookManager() + for hook_type, hooks in self._hooks.items(): + new_manager._hooks[hook_type] = list(hooks) + return new_manager diff --git a/falyx/menu.py b/falyx/menu.py index 2db157d..793af01 100644 --- a/falyx/menu.py +++ b/falyx/menu.py @@ -68,6 +68,14 @@ class MenuOption: [(OneColors.WHITE, f"[{key}] "), (self.style, self.description)] ) + def copy(self) -> MenuOption: + """Create a copy of this MenuOption.""" + return MenuOption( + description=self.description, + action=self.action.clone(), + style=self.style, + ) + class MenuOptionMap(CaseInsensitiveDict): """ @@ -160,3 +168,13 @@ class MenuOptionMap(CaseInsensitiveDict): if not include_reserved and key in self.RESERVED_KEYS: continue yield key, option + + def copy(self) -> MenuOptionMap: + """Create a copy of this MenuOptionMap.""" + items = {} + for key, option in self.items(): + if key in self.RESERVED_KEYS and not self.allow_reserved: + continue + items[key] = option.copy() + new_map = MenuOptionMap(items, allow_reserved=self.allow_reserved) + return new_map diff --git a/falyx/options_manager.py b/falyx/options_manager.py index e8cf85a..b664c9c 100644 --- a/falyx/options_manager.py +++ b/falyx/options_manager.py @@ -37,6 +37,7 @@ Attributes: """ from collections import defaultdict from contextlib import contextmanager +from copy import deepcopy from typing import Any, Callable, Iterator, Mapping from falyx.logger import logger @@ -227,21 +228,50 @@ class OptionsManager: return _toggle - def get_namespace_dict(self, namespace_name: str) -> dict[str, Any]: - """Return a shallow copy of one namespace's option dictionary. + def get_namespace(self, namespace_name: str) -> dict[str, Any] | None: + """Return the option dictionary for a namespace. + + Args: + namespace_name (str): Name of the namespace to retrieve. + + Returns: + dict[str, Any]: The options stored in the requested namespace. + """ + if namespace_name not in self.options: + return None + return self.options[namespace_name] + + def get_namespace_copy(self, namespace_name: str) -> dict[str, Any] | None: + """Return a deep copy of one namespace's option dictionary. Args: namespace_name (str): Namespace to snapshot. Returns: dict[str, Any]: Copy of the namespace's stored options. - - Raises: - ValueError: If the requested namespace does not exist. """ if namespace_name not in self.options: - raise ValueError(f"Namespace '{namespace_name}' not found.") - return dict(self.options[namespace_name]) + return None + return deepcopy(self.options[namespace_name]) + + def seed_missing( + self, + defaults: Mapping[str, Any], + namespace_name: str = "default", + ) -> None: + """Seed missing options in a namespace from a defaults mapping. + + This method only sets options that are not already present in the target + namespace, allowing it to be used for layering default values without + overwriting existing settings. + + Args: + defaults (Mapping[str, Any]): Default option values to seed. + namespace_name (str): Namespace to update. Defaults to `"default"`. + """ + for key, value in defaults.items(): + if key not in self.options[namespace_name]: + self.options[namespace_name][key] = value @contextmanager def override_namespace( @@ -267,9 +297,16 @@ class OptionsManager: Raises: ValueError: If the namespace does not already exist. """ - original = self.get_namespace_dict(namespace_name) + original = self.get_namespace_copy(namespace_name) + if original is None: + raise ValueError( + f"Cannot override non-existent namespace '{namespace_name}'." + ) try: self.from_mapping(values=overrides, namespace_name=namespace_name) yield finally: self.options[namespace_name] = original + + def __str__(self) -> str: + return f"OptionsManager(namespaces={list(self.options.keys())})" diff --git a/falyx/parser/__init__.py b/falyx/parser/__init__.py index 7d29c06..f200c53 100644 --- a/falyx/parser/__init__.py +++ b/falyx/parser/__init__.py @@ -7,7 +7,7 @@ Licensed under the MIT License. See LICENSE file for details. from .argument import Argument from .argument_action import ArgumentAction from .command_argument_parser import CommandArgumentParser -from .falyx_parser import FalyxParser +from .falyx_parser import FalyxParser, OptionAction from .parse_result import ParseResult __all__ = [ @@ -15,5 +15,6 @@ __all__ = [ "ArgumentAction", "CommandArgumentParser", "FalyxParser", + "OptionAction", "ParseResult", ] diff --git a/falyx/parser/argument.py b/falyx/parser/argument.py index ab0addd..fe9b3b2 100644 --- a/falyx/parser/argument.py +++ b/falyx/parser/argument.py @@ -32,6 +32,8 @@ Used By: - Rich-based CLI help generation - Completion and preview suggestions """ +from __future__ import annotations + from dataclasses import dataclass from typing import Any @@ -155,3 +157,23 @@ class Argument: self.mutex_group, ) ) + + def copy(self) -> Argument: + """Create a copy of this Argument.""" + return Argument( + flags=self.flags, + dest=self.dest, + action=self.action, + type=self.type, + default=self.default, + choices=list(self.choices) if self.choices else [], + required=self.required, + help=self.help, + nargs=self.nargs, + positional=self.positional, + resolver=self.resolver, + lazy_resolver=self.lazy_resolver, + suggestions=list(self.suggestions) if self.suggestions else None, + group=self.group, + mutex_group=self.mutex_group, + ) diff --git a/falyx/parser/command_argument_parser.py b/falyx/parser/command_argument_parser.py index 77c8292..0fedeee 100644 --- a/falyx/parser/command_argument_parser.py +++ b/falyx/parser/command_argument_parser.py @@ -46,7 +46,9 @@ general-purpose argparse replacement. """ from __future__ import annotations +import re from collections import Counter, defaultdict +from collections.abc import Callable from copy import deepcopy from pathlib import Path from typing import Any, Generator, Iterable @@ -121,7 +123,7 @@ class _GroupBuilder: action: str | ArgumentAction = "store", nargs: int | str | None = None, default: Any = None, - type: Any = str, + type: Callable[[Any], Any] = str, choices: Iterable | None = None, required: bool = False, help: str = "", @@ -129,8 +131,8 @@ class _GroupBuilder: resolver: BaseAction | None = None, lazy_resolver: bool = True, suggestions: list[str] | None = None, - ) -> None: - self.parser.add_argument( + ) -> Argument: + return self.parser.add_argument( *flags, action=action, nargs=nargs, @@ -201,6 +203,7 @@ class CommandArgumentParser: self.help_epilog: str = help_epilog self.aliases: list[str] = aliases or [] self.program: str | None = program + self._arguments: list[Argument] = [] self._positional: dict[str, Argument] = {} self._keyword: dict[str, Argument] = {} @@ -208,19 +211,26 @@ class CommandArgumentParser: self._flag_map: dict[str, Argument] = {} self._dest_set: set[str] = set() self._execution_dests: set[str] = set() + self._add_help() self._last_positional_states: dict[str, ArgumentState] = {} self._last_keyword_states: dict[str, ArgumentState] = {} + self._argument_groups: dict[str, ArgumentGroup] = {} self._mutex_groups: dict[str, MutuallyExclusiveGroup] = {} self._arg_group_by_dest: dict[str, str] = {} self._mutex_group_by_dest: dict[str, str] = {} + self._tldr_examples: list[TLDRExample] = [] self._is_help_command: bool = False if tldr_examples: self.add_tldr_examples(tldr_examples) self.options_manager: OptionsManager = options_manager or OptionsManager() + self._is_runner_mode: bool = False + self._summary_enabled: bool = False + self._retries_enabled: bool = False + self._confirm_enabled: bool = False def mark_as_help_command(self) -> None: """Mark this parser as the help command parser.""" @@ -247,15 +257,16 @@ class CommandArgumentParser: execution_options: frozenset[ExecutionOption], ) -> None: """Enable support for execution options like retries, summary, etc.""" - if ExecutionOption.SUMMARY in execution_options: + if ExecutionOption.SUMMARY in execution_options and not self._summary_enabled: self.add_argument( "--summary", action=ArgumentAction.STORE_TRUE, help="Print an execution summary after command completes", ) self._register_execution_dest("summary") + self._summary_enabled = True - if ExecutionOption.RETRY in execution_options: + if ExecutionOption.RETRY in execution_options and not self._retries_enabled: self.add_argument( "--retries", type=int, @@ -277,8 +288,9 @@ class CommandArgumentParser: help="Backoff multiplier for retries (e.g. 2.0 doubles the delay each retry)", ) self._register_execution_dest("retry_backoff") + self._retries_enabled = True - if ExecutionOption.CONFIRM in execution_options: + if ExecutionOption.CONFIRM in execution_options and not self._confirm_enabled: self.add_argument( "--confirm", dest="force_confirm", @@ -292,6 +304,7 @@ class CommandArgumentParser: help="Skip confirmation prompts", ) self._register_execution_dest("skip_confirm") + self._confirm_enabled = True def _register_execution_dest(self, dest: str) -> None: """Register a destination as an execution argument.""" @@ -312,6 +325,7 @@ class CommandArgumentParser: action=ArgumentAction.HELP, help="Show this help message.", dest="help", + choices=[], ) self._register_argument(help) @@ -322,6 +336,7 @@ class CommandArgumentParser: action=ArgumentAction.TLDR, help="Show quick usage examples.", dest="tldr", + choices=[], ) self._register_argument(tldr) @@ -544,44 +559,47 @@ class CommandArgumentParser: raise CommandArgumentError( "choices must be iterable (like list, tuple, or set)" ) from error + normalized: list[Any] = [] for choice in choices: try: - coerce_value(choice, expected_type) + normalized.append(coerce_value(choice, expected_type)) except Exception as error: type_name = get_type_name(expected_type) raise CommandArgumentError( f"invalid choice {choice!r}: cannot be coerced to {type_name} error: {error}" ) from error - return choices + return normalized - def _validate_default_type( - self, default: Any, expected_type: type, dest: str - ) -> None: - """Validate the default value type.""" + def _normalize_default_type( + self, default: Any, expected_type: Callable[[Any], Any], dest: str + ) -> Any: + """Normalize the default value type.""" if default is None: return None try: - coerce_value(default, expected_type) + return coerce_value(default, expected_type) except Exception as error: type_name = get_type_name(expected_type) raise CommandArgumentError( f"invalid default value {default!r} for '{dest}' cannot be coerced to {type_name} error: {error}" ) from error - def _validate_default_list_type( - self, default: list[Any], expected_type: type, dest: str - ) -> None: - """Validate the default value type for a list.""" + def _normalize_default_list_type( + self, default: list[Any], expected_type: Callable[[Any], Any], dest: str + ) -> list[Any] | None: + """Normalize the default value type for a list.""" if not isinstance(default, list): return None + normalized: list[Any] = [] for item in default: try: - coerce_value(item, expected_type) + normalized.append(coerce_value(item, expected_type)) except Exception as error: type_name = get_type_name(expected_type) raise CommandArgumentError( f"invalid default list value {default!r} for '{dest}' cannot be coerced to {type_name} error: {error}" ) from error + return normalized def _validate_resolver( self, action: ArgumentAction, resolver: BaseAction | None @@ -699,6 +717,10 @@ class CommandArgumentParser: raise CommandArgumentError( f"invalid flag '{flag}': short flags must be a single character" ) + if not re.match(r"^[a-zA-Z0-9_-]+$", flag.lstrip("-")): + raise CommandArgumentError( + f"invalid flag '{flag}': must only contain letters, digits, underscores, or hyphens" + ) def _register_store_bool_optional( self, @@ -707,7 +729,7 @@ class CommandArgumentParser: help: str, group: str | None, mutex_group: str | None, - ) -> None: + ) -> Argument: """Register a store_bool_optional action with the parser.""" if len(flags) != 1: raise CommandArgumentError( @@ -743,6 +765,7 @@ class CommandArgumentParser: self._register_argument(argument) self._register_argument(negated_argument, bypass_validation=True) + return argument def _register_argument( self, argument: Argument, bypass_validation: bool = False @@ -771,19 +794,19 @@ class CommandArgumentParser: if argument.group: self._arg_group_by_dest[argument.dest] = argument.group - self._argument_groups[argument.group].dests.append(argument.dest) + self._argument_groups[argument.group].dests.add(argument.dest) if argument.mutex_group: self._mutex_group_by_dest[argument.dest] = argument.mutex_group - self._mutex_groups[argument.mutex_group].dests.append(argument.dest) + self._mutex_groups[argument.mutex_group].dests.add(argument.dest) def add_argument( self, - *flags, + *flags: str, action: str | ArgumentAction = "store", nargs: int | str | None = None, default: Any = None, - type: Any = str, + type: Callable[[Any], Any] = str, choices: Iterable | None = None, required: bool = False, help: str = "", @@ -793,7 +816,7 @@ class CommandArgumentParser: suggestions: list[str] | None = None, group: str | None = None, mutex_group: str | None = None, - ) -> None: + ) -> Argument: """Define a new argument for the parser. Supports positional and flagged arguments, type coercion, default values, @@ -841,9 +864,9 @@ class CommandArgumentParser: and default is not None ): if isinstance(default, list): - self._validate_default_list_type(default, type, dest) + default = self._normalize_default_list_type(default, type, dest) else: - self._validate_default_type(default, type, dest) + default = self._normalize_default_type(default, type, dest) choices = self._normalize_choices(choices, type, action) if default is not None and choices: choices_str = ", ".join((str(choice) for choice in choices)) @@ -873,8 +896,9 @@ class CommandArgumentParser: f"lazy_resolver must be a boolean, got {type_name}" ) if action == ArgumentAction.STORE_BOOL_OPTIONAL: - self._register_store_bool_optional(flags, dest, help, group, mutex_group) - return None + return self._register_store_bool_optional( + flags, dest, help, group, mutex_group + ) argument = Argument( flags=flags, dest=dest, @@ -893,6 +917,7 @@ class CommandArgumentParser: mutex_group=mutex_group, ) self._register_argument(argument) + return argument def get_argument(self, dest: str) -> Argument | None: """Return the Argument object for a given destination name. @@ -944,6 +969,8 @@ class CommandArgumentParser: if not spec.choices: return None value_check = result.get(spec.dest) + if not self._is_present(spec, value_check): + return None if isinstance(value_check, list): if all(value in spec.choices for value in value_check): return None @@ -982,23 +1009,23 @@ class CommandArgumentParser: and spec.nargs in ("+", "*", "?") ), f"Invalid nargs value: {spec.nargs}" values = [] + display_name = spec.flags[0] if spec.flags else spec.dest if isinstance(spec.nargs, int): if index + spec.nargs > len(args): raise MissingValueError( - spec.dest, + dest=spec.dest, expected_count=spec.nargs, actual_count=len(args) - index, + display_name=display_name, ) - # raise CommandArgumentError( - # f"Expected {spec.nargs} value(s) for '{spec.dest}' but got {len(args) - index}" - # ) values = args[index : index + spec.nargs] return values, index + spec.nargs elif spec.nargs == "+": if index >= len(args): - raise MissingValueError(spec.dest, expected_count=1) - raise CommandArgumentError( - f"Expected at least one value for '{spec.dest}'" + raise MissingValueError( + dest=spec.dest, + expected_count="+", + display_name=display_name, ) while index < len(args) and args[index] not in self._keyword: values.append(args[index]) @@ -1090,26 +1117,20 @@ class CommandArgumentParser: if spec.nargs == "+" and len(typed) == 0: raise MissingValueError( dest=spec.dest, - expected_count=1, + expected_count="+", ) - # raise CommandArgumentError( - # f"Argument '{spec.dest}' requires at least one value" - # ) if isinstance(spec.nargs, int) and len(typed) != spec.nargs: raise MissingValueError( - spec.dest, + dest=spec.dest, expected_count=spec.nargs, actual_count=len(typed), ) - # raise CommandArgumentError( - # f"Argument '{spec.dest}' requires exactly {spec.nargs} value(s)" - # ) if not spec.lazy_resolver or not from_validate: try: result[spec.dest] = await spec.resolver(*typed) except Exception as error: - raise CommandArgumentError( - f"[{spec.dest}] Action failed: {error}" + raise ArgumentParsingError( + f"[{spec.dest}] action failed: {error}" ) from error self._check_if_in_choices(spec, result, arg_states) arg_states[spec.dest].set_consumed(base_index + index) @@ -1143,8 +1164,8 @@ class CommandArgumentParser: self._raise_remaining_args_error(token, arg_states) else: plural = "s" if len(args[index:]) > 1 else "" - raise CommandArgumentError( - f"Unexpected positional argument{plural}: {', '.join(args[index:])}" + raise ArgumentParsingError( + f"unexpected positional argument{plural}: {', '.join(args[index:])}" ) return index @@ -1211,22 +1232,22 @@ class CommandArgumentParser: if choices: choices.append(help_text) choices_text = ", ".join(choices) - raise CommandArgumentError( - f"Argument '{spec.dest}' requires a value. {choices_text}" + raise ArgumentParsingError( + f"argument '{spec.dest}' requires a value. {choices_text}" ) elif spec.nargs is None: try: type_name = get_type_name(spec.type) - raise CommandArgumentError( - f"Enter a {type_name} value for '{spec.dest}'. {help_text}" + raise ArgumentParsingError( + f"enter a {type_name} value for '{spec.dest}'. {help_text}" ) except AttributeError as error: - raise CommandArgumentError( - f"Enter a value for '{spec.dest}'. {help_text}" + raise ArgumentParsingError( + f"enter a value for '{spec.dest}'. {help_text}" ) from error else: - raise CommandArgumentError( - f"Argument '{spec.dest}' requires a value. Expected {spec.nargs} values. {help_text}" + raise ArgumentParsingError( + f"argument '{spec.dest}' requires a value. Expected {spec.nargs} values. {help_text}" ) async def _handle_token( @@ -1280,8 +1301,8 @@ class CommandArgumentParser: try: result[spec.dest] = await spec.resolver(*typed_values) except Exception as error: - raise CommandArgumentError( - f"[{spec.dest}] Action failed: {error}" + raise ArgumentParsingError( + f"[{spec.dest}] action failed: {error}" ) from error self._check_if_in_choices(spec, result, arg_states) arg_states[spec.dest].set_consumed(new_index) @@ -1431,8 +1452,8 @@ class CommandArgumentParser: present.append(dest) if len(present) > 1: - raise CommandArgumentError( - f"Arguments in mutually exclusive group '{group.name}' " + raise ArgumentParsingError( + f"arguments in mutually exclusive group '{group.name}' " f"cannot be used together: {', '.join(present)}" ) @@ -1442,8 +1463,8 @@ class CommandArgumentParser: spec = self.get_argument(dest) if spec: members.append(spec.flags[0] if spec.flags else dest) - raise CommandArgumentError( - f"One of the following is required for group '{group.name}': " + raise ArgumentParsingError( + f"one of the following is required for group '{group.name}': " f"{', '.join(members)}" ) @@ -1543,8 +1564,8 @@ class CommandArgumentParser: and not arg.default ] if missing_positionals: - raise CommandArgumentError( - f"Missing positional argument(s): {', '.join(missing_positionals)}" + raise ArgumentParsingError( + f"missing positional argument(s): {', '.join(missing_positionals)}" ) # Required validation @@ -1560,13 +1581,13 @@ class CommandArgumentParser: ): if not args: arg_states[spec.dest].reset() - raise CommandArgumentError( - f"Missing required argument '{spec.dest}': {spec.get_choice_text()}{help_text}" + raise ArgumentParsingError( + f"missing required argument '{spec.dest}': {spec.get_choice_text()}{help_text}" ) continue # Lazy resolvers are not validated here arg_states[spec.dest].reset() - raise CommandArgumentError( - f"Missing required argument '{spec.dest}': {spec.get_choice_text()}{help_text}" + raise ArgumentParsingError( + f"missing required argument '{spec.dest}': {spec.get_choice_text()}{help_text}" ) self._check_if_in_choices(spec, result, arg_states) @@ -1611,8 +1632,8 @@ class CommandArgumentParser: help_text = f" help: {spec.help}" if spec.help else "" if not result[spec.dest]: arg_states[spec.dest].reset() - raise CommandArgumentError( - f"Argument '{spec.dest}' requires at least one value{help_text}" + raise ArgumentParsingError( + f"argument '{spec.dest}' requires at least one value{help_text}" ) self._validate_mutex_groups(result) @@ -2322,3 +2343,63 @@ class CommandArgumentParser: def __repr__(self) -> str: return str(self) + + def clone_with_overrides( + self, + *, + command_key: str | None = None, + command_description: str | None = None, + command_style: StyleType | None = None, + help_text: str | None = None, + help_epilog: str | None = None, + aliases: list[str] | None = None, + tldr_examples: list[TLDRInput] | None = None, + program: str | None = None, + options_manager: OptionsManager | None = None, + ) -> CommandArgumentParser: + """Create a copy of this parser with optional overrides for core properties.""" + if tldr_examples is not None: + tldr_examples_copied = tldr_examples + elif self._tldr_examples is not None: + tldr_examples_copied = [example.copy() for example in self._tldr_examples] + else: + tldr_examples_copied = None + parser = CommandArgumentParser( + command_key=command_key if command_key is not None else self.command_key, + command_description=( + command_description + if command_description is not None + else self.command_description + ), + command_style=( + command_style if command_style is not None else self.command_style + ), + help_text=help_text if help_text is not None else self.help_text, + help_epilog=help_epilog if help_epilog is not None else self.help_epilog, + aliases=aliases if aliases is not None else self.aliases.copy(), + program=program if program is not None else self.program, + tldr_examples=tldr_examples_copied, + options_manager=( + options_manager if options_manager is not None else self.options_manager + ), + ) + + parser._argument_groups = { + name: group.copy() for name, group in self._argument_groups.items() + } + parser._mutex_groups = { + name: group.copy() for name, group in self._mutex_groups.items() + } + + for argument in self._arguments: + if argument.dest in {"help", "tldr"}: + continue + parser._register_argument(argument.copy()) + + parser._execution_dests = set(self._execution_dests) + parser._summary_enabled = self._summary_enabled + parser._retries_enabled = self._retries_enabled + parser._confirm_enabled = self._confirm_enabled + parser._is_runner_mode = self._is_runner_mode + parser._is_help_command = self._is_help_command + return parser diff --git a/falyx/parser/falyx_parser.py b/falyx/parser/falyx_parser.py index 6b3ff34..b1d89b8 100644 --- a/falyx/parser/falyx_parser.py +++ b/falyx/parser/falyx_parser.py @@ -1,18 +1,19 @@ # Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed from __future__ import annotations -from dataclasses import dataclass -from enum import Enum +import re +from collections.abc import Callable from typing import TYPE_CHECKING, Any -from falyx.console import console from falyx.exceptions import EntryNotFoundError, FalyxOptionError from falyx.mode import FalyxMode -from falyx.options_manager import OptionsManager +from falyx.parser.option import Option, OptionScope +from falyx.parser.option_action import OptionAction from falyx.parser.parse_result import ParseResult from falyx.parser.parser_types import ( FalyxTLDRExample, FalyxTLDRInput, + OptionState, false_none, true_none, ) @@ -24,79 +25,6 @@ if TYPE_CHECKING: builtin_type = type -class OptionAction(Enum): - STORE = "store" - STORE_TRUE = "store_true" - STORE_FALSE = "store_false" - STORE_BOOL_OPTIONAL = "store_bool_optional" - COUNT = "count" - HELP = "help" - TLDR = "tldr" - - @classmethod - def choices(cls) -> list[OptionAction]: - """Return a list of all argument actions.""" - return list(cls) - - @classmethod - def _get_alias(cls, value: str) -> str: - aliases = { - "optional": "store_bool_optional", - "true": "store_true", - "false": "store_false", - } - return aliases.get(value, value) - - @classmethod - def _missing_(cls, value: object) -> OptionAction: - if not isinstance(value, str): - raise ValueError(f"Invalid {cls.__name__}: {value!r}") - normalized = value.strip().lower() - alias = cls._get_alias(normalized) - for member in cls: - if member.value == alias: - return member - valid = ", ".join(member.value for member in cls) - raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}") - - def __str__(self) -> str: - """Return the string representation of the argument action.""" - return self.value - - -class OptionScope(Enum): - ROOT = "root" - NAMESPACE = "namespace" - - @classmethod - def _missing_(cls, value: object) -> OptionScope: - if not isinstance(value, str): - raise ValueError(f"Invalid {cls.__name__}: {value!r}") - normalized = value.strip().lower() - for member in cls: - if member.value == normalized: - return member - valid = ", ".join(member.value for member in cls) - raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}") - - -@dataclass(slots=True) -class Option: - flags: tuple[str, ...] - dest: str - action: OptionAction = OptionAction.STORE - type: Any = str - default: Any = None - choices: list[str] | None = None - help: str = "" - suggestions: list[str] | None = None - scope: OptionScope = OptionScope.NAMESPACE - - def format_for_help(self) -> str: - """Return a formatted string of the option's flags for help output.""" - return ", ".join(self.flags) - - class FalyxParser: RESERVED_DESTS: set[str] = {"help", "tldr"} @@ -106,9 +34,10 @@ class FalyxParser: self._options: list[Option] = [] self._dest_set: set[str] = set() self._tldr_examples: list[FalyxTLDRExample] = [] - self._add_reserved_options() self.help_option: Option | None = None self.tldr_option: Option | None = None + self._last_option_states: dict[str, OptionState] = {} + self._add_reserved_options() def get_flags(self) -> list[str]: """Return a list of the first flag for the registered options.""" @@ -119,7 +48,7 @@ class FalyxParser: return self._options def _add_tldr(self): - """Add TLDR argument to the parser.""" + """Add TLDR option to the parser.""" if "tldr" in self._dest_set: return None tldr = Option( @@ -208,7 +137,7 @@ class FalyxParser: def _add_reserved_options(self) -> None: help = Option( - flags=("-h", "--help", "?"), + flags=("-h", "--help"), dest="help", action=OptionAction.HELP, help="Show root-level help output and exit.", @@ -255,7 +184,7 @@ class FalyxParser: flags: tuple[str, ...], dest: str, help: str, - ) -> None: + ) -> Option: """Register a store_bool_optional action with the parser.""" if len(flags) != 1: raise FalyxOptionError( @@ -268,7 +197,7 @@ class FalyxParser: base_flag = flags[0] negated_flag = f"--no-{base_flag.lstrip('-')}" - argument = Option( + option = Option( flags=flags, dest=dest, action=OptionAction.STORE_BOOL_OPTIONAL, @@ -277,7 +206,7 @@ class FalyxParser: help=help, ) - negated_argument = Option( + negated_option = Option( flags=(negated_flag,), dest=dest, action=OptionAction.STORE_BOOL_OPTIONAL, @@ -286,17 +215,19 @@ class FalyxParser: help=help, ) - self._register_option(argument) - self._register_option(negated_argument, bypass_validation=True) + self._register_option(option) + self._register_option(negated_option, bypass_validation=True) + return option def _register_option(self, option: Option, bypass_validation: bool = False) -> None: self._dest_set.add(option.dest) self._options.append(option) + self._last_option_states[option.dest] = OptionState(option) for flag in option.flags: - if flag in self._options and not bypass_validation: + if flag in self._options_by_dest and not bypass_validation: existing = self._options_by_dest[flag] raise FalyxOptionError( - f"flag '{flag}' is already used by argument '{existing.dest}'" + f"flag '{flag}' is already used by option '{existing.dest}'" ) self._options_by_dest[flag] = option @@ -319,7 +250,11 @@ class FalyxParser: if flag in self._options_by_dest: existing = self._options_by_dest[flag] raise FalyxOptionError( - f"flag '{flag}' is already used by argument '{existing.dest}'" + f"flag '{flag}' is already used by option '{existing.dest}'" + ) + if not re.match(r"^[a-zA-Z0-9_-]+$", flag.lstrip("-")): + raise FalyxOptionError( + f"invalid flag '{flag}': must only contain letters, digits, underscores, or hyphens" ) def _get_dest_from_flags(self, flags: tuple[str, ...], dest: str | None) -> str: @@ -389,16 +324,16 @@ class FalyxParser: raise FalyxOptionError(f"default value cannot be set for action '{action}'.") return default - def _validate_default_type( + def _normalize_default_type( self, default: Any, expected_type: Any, dest: str, - ) -> None: + ) -> Any: if default is None: return None try: - coerce_value(default, expected_type) + return coerce_value(default, expected_type) except Exception as error: type_name = get_type_name(expected_type) raise FalyxOptionError( @@ -408,7 +343,7 @@ class FalyxParser: def _normalize_choices( self, choices: list[str] | None, - expected_type: type, + expected_type: Callable[[Any], Any], action: OptionAction, ) -> list[Any]: if choices is None: @@ -430,27 +365,28 @@ class FalyxParser: raise FalyxOptionError( "choices must be iterable (like list, tuple, or set)" ) from error + normalized: list[Any] = [] for choice in choices: try: - coerce_value(choice, expected_type) + normalized.append(coerce_value(choice, expected_type)) except Exception as error: type_name = get_type_name(expected_type) raise FalyxOptionError( f"invalid choice {choice!r} cannot be coerced to {type_name} error: {error}" ) from error - return choices + return normalized def add_option( self, - flags: tuple[str, ...], - dest: str, + *flags: str, action: str | OptionAction = "store", - type: type = str, default: Any = None, + type: Callable[[Any], Any] = str, choices: list[str] | None = None, help: str = "", + dest: str | None = None, suggestions: list[str] | None = None, - ) -> None: + ) -> Option: self._validate_flags(flags) dest = self._get_dest_from_flags(flags, dest) if dest in self.RESERVED_DESTS: @@ -461,7 +397,10 @@ class FalyxParser: raise FalyxOptionError(f"duplicate option dest '{dest}'") action = self._validate_action(action) default = self._resolve_default(default, action) - self._validate_default_type(default, type, dest) + + if action is OptionAction.STORE: + default = self._normalize_default_type(default, type, dest) + choices = self._normalize_choices(choices, type, action) if default is not None and choices and default not in choices: choices_str = ", ".join((str(choice) for choice in choices)) @@ -476,8 +415,7 @@ class FalyxParser: ): raise FalyxOptionError("suggestions must be a list of strings") if action is OptionAction.STORE_BOOL_OPTIONAL: - self._register_store_bool_optional(flags, dest, help) - return None + return self._register_store_bool_optional(flags, dest, help) option = Option( flags=flags, dest=dest, @@ -489,16 +427,86 @@ class FalyxParser: suggestions=suggestions, ) self._register_option(option) + return option - def apply_to_options( + def _filter_suggestions( self, - parse_result: ParseResult, - options: OptionsManager, - ) -> None: - for dest, value in parse_result.options.items(): - options.set(dest, value, namespace_name=self_flx.namespace_name) - for dest, value in parse_result.root_options.items(): - options.set(dest, value, namespace_name="root") + suggestion: str, + prefix: str, + cursor_at_end_of_token: bool, + ) -> bool: + if cursor_at_end_of_token: + return True + return suggestion.startswith(prefix) + + def _value_suggestions_for_option( + self, + option: Option, + prefix: str, + cursor_at_end_of_token: bool, + ) -> list[str]: + if option.choices: + return [ + str(choice) + for choice in option.choices + if self._filter_suggestions(str(choice), prefix, cursor_at_end_of_token) + ] + if option.suggestions: + return [ + suggestion + for suggestion in option.suggestions + if self._filter_suggestions(suggestion, prefix, cursor_at_end_of_token) + ] + return [] + + def suggest_next( + self, + args: list[str], + cursor_at_end_of_token: bool, + ) -> tuple[list[str], bool]: + """Suggest the next possible flags based on the current input stub.""" + expecting_value = False + if not args: + return [], expecting_value + options = self._resolve_posix_bundling(args) + consumed_dests = [ + state.option.dest + for state in self._last_option_states.values() + if state.consumed + ] + + remaining_flags = [ + flag + for flag, option in self._options_by_dest.items() + if option.dest not in consumed_dests + ] + + last = options[-1] if options else "" + + last_option_in_options = None + for option in reversed(options): + if option in self._options_by_dest: + last_option_in_options = self._options_by_dest[option] + break + + suggestions: list[str] = [] + if last.startswith("-") and last not in self._options_by_dest: + suggestions.extend(flag for flag in remaining_flags if flag.startswith(last)) + elif ( + last_option_in_options + and not self._last_option_states[last_option_in_options.dest].consumed + ): + suggestions.extend( + self._value_suggestions_for_option( + last_option_in_options, + prefix=last, + cursor_at_end_of_token=cursor_at_end_of_token, + ) + ) + if last_option_in_options.action is OptionAction.STORE: + expecting_value = True + + return suggestions, expecting_value def _can_bundle_option(self, option: Option) -> bool: return option.action in { @@ -510,7 +518,7 @@ class FalyxParser: } def _resolve_posix_bundling(self, tokens: list[str]) -> list[str]: - """Expand POSIX-style bundled arguments into separate arguments.""" + """Expand POSIX-style bundled options into separate options.""" expanded: list[str] = [] for token in tokens: if not token.startswith("-") or token.startswith("--") or len(token) <= 2: @@ -552,30 +560,37 @@ class FalyxParser: argv: list[str], index: int, values: dict[str, Any], + option_states: dict[str, OptionState], ) -> int: match option.action: case OptionAction.STORE_TRUE: values[option.dest] = True + option_states[option.dest].set_consumed() return index + 1 case OptionAction.STORE_FALSE: values[option.dest] = False + option_states[option.dest].set_consumed() return index + 1 case OptionAction.STORE_BOOL_OPTIONAL: - values[option.dest] = option.type(None) + values[option.dest] = option.type(True) + option_states[option.dest].set_consumed() return index + 1 case OptionAction.COUNT: values[option.dest] = int(values.get(option.dest) or 0) + 1 + option_states[option.dest].set_consumed() return index + 1 case OptionAction.HELP: values[option.dest] = True + option_states[option.dest].set_consumed() return index + 1 case OptionAction.TLDR: values[option.dest] = True + option_states[option.dest].set_consumed() return index + 1 case OptionAction.STORE: @@ -598,6 +613,7 @@ class FalyxParser: ) values[option.dest] = value + option_states[option.dest].set_consumed() return index + 2 raise FalyxOptionError(f"unsupported option action: {option.action}") @@ -606,19 +622,17 @@ class FalyxParser: self, argv: list[str] | None = None, ) -> ParseResult: + option_states = {option.dest: OptionState(option) for option in self._options} + self._last_option_states = option_states raw_argv = argv or [] arguments = self._resolve_posix_bundling(raw_argv) - values, root_values = self._default_values() + root_options: dict[str, Any] = {} + namespace_options: dict[str, Any] = {} index = 0 while index < len(arguments): token = arguments[index] - # Explicit option terminator. Everything after belongs to routing/command. - if token == "--": - index += 1 - break - # First non-option is the route boundary. if not token.startswith("-"): break @@ -631,20 +645,33 @@ class FalyxParser: f"unknown option '{token}' for '{self._flx.program or self._flx.title}'" ) - target_values = root_values if option.scope == OptionScope.ROOT else values - index = self._consume_option(option, arguments, index, target_values) + target_values = ( + root_options if option.scope == OptionScope.ROOT else namespace_options + ) + index = self._consume_option( + option, + arguments, + index, + target_values, + option_states, + ) remaining_argv = arguments[index:] - help_requested = values.get("help", False) or values.get("tldr", False) + help_requested = namespace_options.get("help", False) or namespace_options.get( + "tldr", False + ) + namespace_defaults, root_defaults = self._default_values() return ParseResult( mode=FalyxMode.HELP if help_requested else FalyxMode.COMMAND, raw_argv=raw_argv, - options=values, - root_options=root_values, + root_defaults=root_defaults, + root_options=root_options, + namespace_defaults=namespace_defaults, + namespace_options=namespace_options, remaining_argv=remaining_argv, - help=values.get("help", False), - tldr=values.get("tldr", False), + help=namespace_options.get("help", False), + tldr=namespace_options.get("tldr", False), current_head=remaining_argv[0] if remaining_argv else "", ) diff --git a/falyx/parser/group.py b/falyx/parser/group.py index 81ee02a..4a5a01d 100644 --- a/falyx/parser/group.py +++ b/falyx/parser/group.py @@ -46,7 +46,15 @@ class ArgumentGroup: name: str description: str = "" - dests: list[str] = field(default_factory=list) + dests: set[str] = field(default_factory=set) + + def copy(self) -> ArgumentGroup: + """Create a copy of this ArgumentGroup.""" + return ArgumentGroup( + name=self.name, + description=self.description, + dests=set(self.dests), + ) @dataclass(slots=True) @@ -73,4 +81,13 @@ class MutuallyExclusiveGroup: name: str required: bool = False description: str = "" - dests: list[str] = field(default_factory=list) + dests: set[str] = field(default_factory=set) + + def copy(self) -> MutuallyExclusiveGroup: + """Create a copy of this MutuallyExclusiveGroup.""" + return MutuallyExclusiveGroup( + name=self.name, + required=self.required, + description=self.description, + dests=set(self.dests), + ) diff --git a/falyx/parser/option.py b/falyx/parser/option.py new file mode 100644 index 0000000..9cb054d --- /dev/null +++ b/falyx/parser/option.py @@ -0,0 +1,41 @@ +# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Any + +from falyx.parser.option_action import OptionAction + + +class OptionScope(Enum): + ROOT = "root" + NAMESPACE = "namespace" + + @classmethod + def _missing_(cls, value: object) -> OptionScope: + if not isinstance(value, str): + raise ValueError(f"Invalid {cls.__name__}: {value!r}") + normalized = value.strip().lower() + for member in cls: + if member.value == normalized: + return member + valid = ", ".join(member.value for member in cls) + raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}") + + +@dataclass(slots=True) +class Option: + flags: tuple[str, ...] + dest: str + action: OptionAction = OptionAction.STORE + type: Any = str + default: Any = None + choices: list[str] | None = None + help: str = "" + suggestions: list[str] | None = None + scope: OptionScope = OptionScope.NAMESPACE + + def format_for_help(self) -> str: + """Return a formatted string of the option's flags for help output.""" + return ", ".join(self.flags) diff --git a/falyx/parser/option_action.py b/falyx/parser/option_action.py new file mode 100644 index 0000000..67774c9 --- /dev/null +++ b/falyx/parser/option_action.py @@ -0,0 +1,44 @@ +# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed +from __future__ import annotations + +from enum import Enum + + +class OptionAction(Enum): + STORE = "store" + STORE_TRUE = "store_true" + STORE_FALSE = "store_false" + STORE_BOOL_OPTIONAL = "store_bool_optional" + COUNT = "count" + HELP = "help" + TLDR = "tldr" + + @classmethod + def choices(cls) -> list[OptionAction]: + """Return a list of all option actions.""" + return list(cls) + + @classmethod + def _get_alias(cls, value: str) -> str: + aliases = { + "optional": "store_bool_optional", + "true": "store_true", + "false": "store_false", + } + return aliases.get(value, value) + + @classmethod + def _missing_(cls, value: object) -> OptionAction: + if not isinstance(value, str): + raise ValueError(f"Invalid {cls.__name__}: {value!r}") + normalized = value.strip().lower() + alias = cls._get_alias(normalized) + for member in cls: + if member.value == alias: + return member + valid = ", ".join(member.value for member in cls) + raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}") + + def __str__(self) -> str: + """Return the string representation of the option action.""" + return self.value diff --git a/falyx/parser/parse_result.py b/falyx/parser/parse_result.py index 0fcb3c6..3ce73a2 100644 --- a/falyx/parser/parse_result.py +++ b/falyx/parser/parse_result.py @@ -38,9 +38,11 @@ class ParseResult: Attributes: mode: Top-level runtime mode selected from the root parse. raw_argv: Original argv passed into the root parser. - options: Dictionary of parsed root-level options and their values. + root_defaults: Dictionary of parsed root-level options and their default values. root_options: Dictionary of parsed root-level options that should be applied at the root level for all namespaces. + namespace_defaults: Dictionary of parsed namespace-level options and their default values. + namespace_options: Dictionary of parsed namespace-level options and their values. remaining_argv: Unconsumed argv that should be forwarded to routed command resolution. current_head: The current head token being processed (for error reporting). @@ -53,12 +55,11 @@ class ParseResult: mode: FalyxMode raw_argv: list[str] = field(default_factory=list) - options: dict[str, Any] = field(default_factory=dict) + root_defaults: dict[str, Any] = field(default_factory=dict) root_options: dict[str, Any] = field(default_factory=dict) + namespace_defaults: dict[str, Any] = field(default_factory=dict) + namespace_options: dict[str, Any] = field(default_factory=dict) remaining_argv: list[str] = field(default_factory=list) current_head: str = "" help: bool = False tldr: bool = False - verbose: bool = False - debug_hooks: bool = False - never_prompt: bool = False diff --git a/falyx/parser/parser_types.py b/falyx/parser/parser_types.py index 6b275c2..40af413 100644 --- a/falyx/parser/parser_types.py +++ b/falyx/parser/parser_types.py @@ -15,21 +15,16 @@ Contents: These tools support richer expressiveness and user-friendly ergonomics in Falyx's declarative command-line interfaces. """ +from __future__ import annotations + from dataclasses import dataclass from typing import Any, TypeAlias from falyx.parser.argument import Argument +from falyx.parser.option import Option -@dataclass -class ArgumentState: - """Tracks an argument and whether it has been consumed.""" - - arg: Argument - consumed: bool = False - consumed_position: int | None = None - has_invalid_choice: bool = False - +class StateMixin: def set_consumed(self, position: int | None = None) -> None: """Mark this argument as consumed, optionally setting the position.""" self.consumed = True @@ -41,6 +36,26 @@ class ArgumentState: self.consumed_position = None +@dataclass +class ArgumentState(StateMixin): + """Tracks an argument and whether it has been consumed.""" + + arg: Argument + consumed: bool = False + consumed_position: int | None = None + has_invalid_choice: bool = False + + +@dataclass +class OptionState(StateMixin): + """Tracks an option argument and its consumed state, including the dest name.""" + + option: Option + consumed: bool = False + consumed_position: int | None = None + has_invalid_choice: bool = False + + @dataclass(frozen=True) class TLDRExample: """Represents a usage example for TLDR output.""" @@ -48,6 +63,13 @@ class TLDRExample: usage: str description: str + def copy(self) -> TLDRExample: + """Create a copy of this TLDRExample.""" + return TLDRExample( + usage=self.usage, + description=self.description, + ) + TLDRInput: TypeAlias = TLDRExample | tuple[str, str] @@ -60,6 +82,14 @@ class FalyxTLDRExample: usage: str description: str + def copy(self) -> FalyxTLDRExample: + """Create a copy of this FalyxTLDRExample.""" + return FalyxTLDRExample( + entry_key=self.entry_key, + usage=self.usage, + description=self.description, + ) + FalyxTLDRInput: TypeAlias = FalyxTLDRExample | tuple[str, str, str] diff --git a/falyx/parser/utils.py b/falyx/parser/utils.py index 1dc0849..627114d 100644 --- a/falyx/parser/utils.py +++ b/falyx/parser/utils.py @@ -12,6 +12,7 @@ Functions: - same_argument_definitions: Check if multiple callables share the same argument structure. """ import types +from collections.abc import Callable from datetime import datetime from enum import EnumMeta from typing import Any, Literal, Union, get_args, get_origin @@ -87,14 +88,14 @@ def coerce_enum(value: Any, enum_type: EnumMeta) -> Any: raise ValueError(f"'{value}' should be one of {{{', '.join(values)}}}") from None -def coerce_value(value: str, target_type: type) -> Any: +def coerce_value(value: str, target_type: Callable[[Any], Any]) -> Any: """Attempt to convert a string to the given target type. Handles complex typing constructs such as Union, Literal, Enum, and datetime. Args: value (str): The input string to convert. - target_type (type): The desired type. + target_type (Callable[[Any], Any]): The desired type. Returns: Any: The coerced value. diff --git a/falyx/prompt_utils.py b/falyx/prompt_utils.py index b1e32fd..33d2ecc 100644 --- a/falyx/prompt_utils.py +++ b/falyx/prompt_utils.py @@ -44,6 +44,7 @@ def should_prompt_user( *, confirm: bool, options: OptionsManager, + action_never_prompt: bool | None = None, namespace: str = "root", override_namespace: str = "execution", ) -> bool: @@ -57,27 +58,30 @@ def should_prompt_user( Args: confirm (bool): The initial confirmation flag (e.g., from a command argument). options (OptionsManager): The options manager to check for override flags. - namespace (str): The primary namespace to check for options (default: "root"). - override_namespace (str): The secondary namespace for overrides (default: "execution"). + namespace (str): The secondary namespace to check for options (default: "root"). + override_namespace (str): The primary namespace for overrides (default: "execution"). Returns: bool: True if the user should be prompted, False if confirmation can be bypassed. """ + if action_never_prompt is True: + return False + + skip_confirm = options.get("skip_confirm", None, override_namespace) + if skip_confirm: + return False + never_prompt = options.get("never_prompt", None, override_namespace) if never_prompt is None: never_prompt = options.get("never_prompt", False, namespace) + if never_prompt: + return False + force_confirm = options.get("force_confirm", None, override_namespace) if force_confirm is None: force_confirm = options.get("force_confirm", False, namespace) - skip_confirm = options.get("skip_confirm", None, override_namespace) - if skip_confirm is None: - skip_confirm = options.get("skip_confirm", False, namespace) - - if never_prompt or skip_confirm: - return False - return confirm or force_confirm diff --git a/falyx/routing.py b/falyx/routing.py index 54b5b45..42f2eb1 100644 --- a/falyx/routing.py +++ b/falyx/routing.py @@ -23,7 +23,7 @@ from __future__ import annotations from dataclasses import dataclass, field from enum import Enum -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from falyx.context import InvocationContext from falyx.namespace import FalyxNamespace @@ -82,6 +82,8 @@ class RouteResult: generating suggestions. suggestions: Suggested entry names for unresolved input. is_preview: Whether the routed invocation is in preview mode. + root_overrides: Root-level option overrides to apply for this route. + namespace_overrides: Namespace-level option overrides to apply for this route. """ kind: RouteKind @@ -93,3 +95,7 @@ class RouteResult: current_head: str = "" suggestions: list[str] = field(default_factory=list) is_preview: bool = False + root_defaults: dict[str, Any] = field(default_factory=dict) + root_overrides: dict[str, Any] = field(default_factory=dict) + namespace_defaults: dict[str, Any] = field(default_factory=dict) + namespace_overrides: dict[str, Any] = field(default_factory=dict) diff --git a/falyx/selection.py b/falyx/selection.py index 8163ad0..cbfb942 100644 --- a/falyx/selection.py +++ b/falyx/selection.py @@ -11,6 +11,8 @@ It supports: Used by `SelectionAction` and other prompt-driven workflows within Falyx. """ +from __future__ import annotations + from dataclasses import dataclass from typing import Any, Callable, KeysView, Sequence @@ -43,6 +45,14 @@ class SelectionOption: key = escape(f"[{key}]") return f"[{OneColors.WHITE}]{key}[/] [{self.style}]{self.description}[/]" + def copy(self) -> SelectionOption: + """Create a copy of the SelectionOption.""" + return SelectionOption( + description=self.description, + value=self.value, + style=self.style, + ) + class SelectionOptionMap(CaseInsensitiveDict): """Manages selection options including validation and reserved key protection.""" @@ -97,6 +107,13 @@ class SelectionOptionMap(CaseInsensitiveDict): continue yield k, v + def copy(self) -> SelectionOptionMap: + """Create a copy of the SelectionOptionMap.""" + new_map = SelectionOptionMap(allow_reserved=self.allow_reserved) + for key, option in self.items(): + new_map[key] = option.copy() + return new_map + def render_table_base( title: str, diff --git a/falyx/utils.py b/falyx/utils.py index 43a25f1..7f121de 100644 --- a/falyx/utils.py +++ b/falyx/utils.py @@ -91,6 +91,9 @@ class CaseInsensitiveDict(dict): def __getitem__(self, key): return super().__getitem__(self._normalize_key(key)) + def __delitem__(self, key): + super().__delitem__(self._normalize_key(key)) + def __contains__(self, key): return super().__contains__(self._normalize_key(key)) diff --git a/tests/test_actions/test_clone.py b/tests/test_actions/test_clone.py new file mode 100644 index 0000000..2ce7c8c --- /dev/null +++ b/tests/test_actions/test_clone.py @@ -0,0 +1,334 @@ +import pytest + +from falyx.action.action import Action +from falyx.action.action_group import ActionGroup +from falyx.action.chained_action import ChainedAction +from falyx.action.http_action import HTTPAction +from falyx.action.menu_action import MenuAction +from falyx.action.process_action import ProcessAction +from falyx.hook_manager import HookType +from falyx.menu import MenuOption, MenuOptionMap +from falyx.retry import RetryHandler, RetryPolicy + + +def _retry_hooks(action) -> list: + return [ + hook + for hook in action.hooks._hooks[HookType.ON_ERROR] + if isinstance(getattr(hook, "__self__", None), RetryHandler) + ] + + +def _non_retry_error_hooks(action) -> list: + return [ + hook + for hook in action.hooks._hooks[HookType.ON_ERROR] + if not isinstance(getattr(hook, "__self__", None), RetryHandler) + ] + + +def _before_hooks(action) -> list: + return list(action.hooks._hooks[HookType.BEFORE]) + + +def test_action_group_clone_recursively_isolates_nested_action_graph(): + nested_chain = ChainedAction( + name="nested-chain", + actions=[ + Action("step-two", lambda: "two"), + Action("step-three", lambda: "three"), + ], + ) + original = ActionGroup( + name="group", + actions=[ + Action("step-one", lambda: "one"), + nested_chain, + ], + ) + + cloned = original.clone() + + assert cloned is not original + assert cloned.actions is not original.actions + assert len(cloned.actions) == len(original.actions) + + # Top-level children are cloned. + assert cloned.actions[0] is not original.actions[0] + assert cloned.actions[1] is not original.actions[1] + + # Nested action graph is also cloned. + assert isinstance(cloned.actions[1], ChainedAction) + assert cloned.actions[1].actions is not original.actions[1].actions + for cloned_child, original_child in zip( + cloned.actions[1].actions, + original.actions[1].actions, + strict=True, + ): + assert cloned_child is not original_child + assert cloned_child.name == original_child.name + + # Mutating the clone does not mutate the original. + cloned.actions.append(Action("step-four", lambda: "four")) + assert len(cloned.actions) == 3 + assert len(original.actions) == 2 + + cloned.actions[1].actions.append(Action("step-five", lambda: "five")) + assert len(cloned.actions[1].actions) == 3 + assert len(original.actions[1].actions) == 2 + + +def test_menu_action_clone_copies_menu_option_map_and_clones_contained_actions(): + menu_options = MenuOptionMap(disable_reserved=True) + menu_options["A"] = MenuOption( + description="Alpha", + action=Action("alpha-action", lambda: "alpha"), + ) + + original = MenuAction( + name="main-menu", + menu_options=menu_options, + title="Main Menu", + ) + + cloned = original.clone() + + assert cloned is not original + assert cloned.menu_options is not original.menu_options + + assert cloned.menu_options["A"] is not original.menu_options["A"] + assert cloned.menu_options["A"].description == original.menu_options["A"].description + + # Contained action should also be cloned. + assert cloned.menu_options["A"].action is not original.menu_options["A"].action + assert cloned.menu_options["A"].action.name == original.menu_options["A"].action.name + + # Mutating the clone should not affect the original. + cloned.menu_options["A"].description = "Changed" + assert original.menu_options["A"].description == "Alpha" + + cloned.menu_options["B"] = MenuOption( + description="Beta", + action=Action("beta-action", lambda: "beta"), + ) + assert "B" in cloned.menu_options + assert "B" not in original.menu_options + + +def test_process_action_clone_does_not_reuse_runtime_only_executor_state(): + original = ProcessAction( + name="proc", + action=lambda x: x + 1, + args=(1,), + kwargs={"y": 2}, + ) + + original.executor = object() + + cloned = original.clone() + + assert cloned is not original + assert cloned.hooks is not original.hooks + assert cloned.args == original.args + assert cloned.kwargs == original.kwargs + + assert cloned.executor is not original.executor + + +def test_http_action_clone_preserves_retry_policy_without_duplicating_spinner_hooks(): + retry_policy = RetryPolicy(max_retries=3, delay=1.5, backoff=2.0) + retry_policy.enable_policy() + + original = HTTPAction( + name="get-users", + method="GET", + url="https://example.com/api/users", + headers={"Authorization": "Bearer token"}, + params={"page": 1}, + retry_policy=retry_policy, + spinner=True, + ) + + before_count = len(original.hooks._hooks[HookType.BEFORE]) + teardown_count = len(original.hooks._hooks[HookType.ON_TEARDOWN]) + error_count = len(original.hooks._hooks[HookType.ON_ERROR]) + + cloned = original.clone() + + assert cloned is not original + assert cloned.hooks is not original.hooks + + assert cloned.retry_policy is not original.retry_policy + assert cloned.retry_policy.enabled is original.retry_policy.enabled + assert cloned.retry_policy.max_retries == original.retry_policy.max_retries + assert cloned.retry_policy.delay == original.retry_policy.delay + assert cloned.retry_policy.backoff == original.retry_policy.backoff + + assert len(cloned.hooks._hooks[HookType.BEFORE]) == before_count + assert len(cloned.hooks._hooks[HookType.ON_TEARDOWN]) == teardown_count + assert len(cloned.hooks._hooks[HookType.ON_ERROR]) == error_count + + +@pytest.mark.asyncio +async def test_action_clone_registers_exactly_one_retry_hook(): + async def flaky(): + return "ok" + + policy = RetryPolicy(max_retries=2, delay=0.1, backoff=2.0) + policy.enable_policy() + + original = Action( + "flaky", + flaky, + retry_policy=policy, + ) + + cloned = original.clone() + + original_retry_hooks = _retry_hooks(original) + cloned_retry_hooks = _retry_hooks(cloned) + + assert len(original_retry_hooks) == 1 + assert len(cloned_retry_hooks) == 1 + + assert cloned_retry_hooks[0] is not original_retry_hooks[0] + assert getattr(cloned_retry_hooks[0], "__self__", None) is not getattr( + original_retry_hooks[0], "__self__", None + ) + + +def test_action_clone_preserves_non_retry_hooks_without_duplication(): + calls = [] + + async def custom_error_hook(context): + calls.append(context.name) + + original = Action("demo", lambda: "ok") + original.hooks.register(HookType.BEFORE, lambda context: None) + original.hooks.register(HookType.ON_ERROR, custom_error_hook) + + cloned = original.clone() + + assert len(_before_hooks(cloned)) == len(_before_hooks(original)) + assert len(_non_retry_error_hooks(cloned)) == len(_non_retry_error_hooks(original)) + + assert cloned.hooks is not original.hooks + + +def test_action_clone_copies_retry_policy_without_sharing_it(): + policy = RetryPolicy(max_retries=2, delay=0.25, backoff=3.0) + policy.enable_policy() + + original = Action( + "demo", + lambda: "ok", + retry_policy=policy, + ) + + cloned = original.clone() + + assert cloned.retry_policy is not original.retry_policy + assert cloned.retry_policy.enabled is original.retry_policy.enabled + assert cloned.retry_policy.max_retries == original.retry_policy.max_retries + assert cloned.retry_policy.delay == original.retry_policy.delay + assert cloned.retry_policy.backoff == original.retry_policy.backoff + + cloned.retry_policy.max_retries = 9 + assert original.retry_policy.max_retries == 2 + + +@pytest.mark.asyncio +async def test_action_clone_retry_behavior_still_works_independently(): + state = {"original": 0, "clone": 0} + + async def flaky_original(): + if state["original"] == 0: + state["original"] += 1 + raise RuntimeError("boom") + return "original-ok" + + async def flaky_clone(): + if state["clone"] == 0: + state["clone"] += 1 + raise RuntimeError("boom") + return "clone-ok" + + policy = RetryPolicy(max_retries=1, delay=0.0, backoff=1.0) + policy.enable_policy() + + original = Action("orig", flaky_original, retry_policy=policy) + cloned = original.clone() + + cloned.action = flaky_clone + + original_result = await original() + cloned_result = await cloned() + + assert original_result == "original-ok" + assert cloned_result == "clone-ok" + assert state["original"] == 1 + assert state["clone"] == 1 + + +def test_http_action_clone_registers_exactly_one_retry_hook(): + policy = RetryPolicy(max_retries=2, delay=0.1, backoff=2.0) + policy.enable_policy() + + original = HTTPAction( + name="get-users", + method="GET", + url="https://example.com/api/users", + retry_policy=policy, + spinner=True, + ) + + cloned = original.clone() + + original_retry_hooks = _retry_hooks(original) + cloned_retry_hooks = _retry_hooks(cloned) + + assert len(original_retry_hooks) == 1 + assert len(cloned_retry_hooks) == 1 + assert cloned_retry_hooks[0] is not original_retry_hooks[0] + + +def test_http_action_clone_copies_retry_policy_without_sharing_it(): + policy = RetryPolicy(max_retries=3, delay=1.5, backoff=2.0) + policy.enable_policy() + + original = HTTPAction( + name="get-users", + method="GET", + url="https://example.com/api/users", + retry_policy=policy, + ) + + cloned = original.clone() + + assert cloned.retry_policy is not original.retry_policy + assert cloned.retry_policy.enabled is original.retry_policy.enabled + assert cloned.retry_policy.max_retries == original.retry_policy.max_retries + assert cloned.retry_policy.delay == original.retry_policy.delay + assert cloned.retry_policy.backoff == original.retry_policy.backoff + + +def test_http_action_clone_does_not_duplicate_spinner_hooks(): + policy = RetryPolicy(max_retries=1, delay=0.0, backoff=1.0) + policy.enable_policy() + + original = HTTPAction( + name="get-users", + method="GET", + url="https://example.com/api/users", + retry_policy=policy, + spinner=True, + ) + + cloned = original.clone() + + assert len(cloned.hooks._hooks[HookType.BEFORE]) == len( + original.hooks._hooks[HookType.BEFORE] + ) + assert len(cloned.hooks._hooks[HookType.ON_TEARDOWN]) == len( + original.hooks._hooks[HookType.ON_TEARDOWN] + ) diff --git a/tests/test_actions/test_save_file_action.py b/tests/test_actions/test_save_file_action.py new file mode 100644 index 0000000..9acda6a --- /dev/null +++ b/tests/test_actions/test_save_file_action.py @@ -0,0 +1,430 @@ +from __future__ import annotations + +import csv +import json +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Any + +import pytest +import toml +import yaml +from rich.tree import Tree + +from falyx.action.action_types import FileType +from falyx.action.save_file_action import SaveFileAction +from falyx.hook_manager import HookType + + +class CaptureConsole: + def __init__(self) -> None: + self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] + + def print(self, *args: Any, **kwargs: Any) -> None: + self.printed.append((args, kwargs)) + + +def make_action(file_path: Path | str | None, **overrides: Any) -> SaveFileAction: + defaults: dict[str, Any] = { + "name": "SaveOutput", + "file_path": file_path, + } + defaults.update(overrides) + return SaveFileAction(**defaults) + + +def register_lifecycle_hooks(action: SaveFileAction) -> list[tuple[HookType, Any]]: + calls: list[tuple[HookType, Any]] = [] + + def make_hook(hook_type: HookType): + def hook(context: Any) -> None: + calls.append((hook_type, context)) + + return hook + + for hook_type in HookType: + action.hooks.register(hook_type, make_hook(hook_type)) + + return calls + + +def hook_types(calls: list[tuple[HookType, Any]]) -> list[HookType]: + return [hook_type for hook_type, _ in calls] + + +def test_init_normalizes_configuration_and_string_file_type(tmp_path: Path) -> None: + target = tmp_path / "output.json" + + action = SaveFileAction( + name="SaveJson", + file_path=str(target), + file_type="json", + mode="a", + encoding="utf-8", + data={"name": "falyx"}, + overwrite=False, + create_dirs=False, + inject_last_result=True, + inject_into="payload", + never_prompt=True, + ) + + assert action.name == "SaveJson" + assert action.file_path == target + assert action.file_type == FileType.JSON + assert action.mode == "a" + assert action.encoding == "utf-8" + assert action.data == {"name": "falyx"} + assert action.overwrite is False + assert action.create_dirs is False + assert action.inject_last_result is True + assert action.inject_into == "payload" + assert action.local_never_prompt is True + assert "SaveFileAction" in str(action) + assert "output.json" in str(action) + + +def test_file_path_property_coerces_string_path_and_none(tmp_path: Path) -> None: + action = make_action(None) + + assert action.file_path is None + + target = tmp_path / "later.txt" + action.file_path = str(target) + + assert action.file_path == target + + action.file_path = target + + assert action.file_path == target + + +def test_file_path_rejects_unsupported_values(tmp_path: Path) -> None: + action = make_action(tmp_path / "out.txt") + + with pytest.raises(TypeError, match="file_path must be a string or Path object"): + action.file_path = 123 # type: ignore[assignment] + + +def test_get_infer_target_disables_signature_inference(tmp_path: Path) -> None: + action = make_action(tmp_path / "out.txt") + + assert action.get_infer_target() == (None, None) + + +def test_dict_to_xml_serializes_nested_dicts_lists_and_scalars(tmp_path: Path) -> None: + action = make_action(tmp_path / "out.xml", file_type=FileType.XML) + root = ET.Element("root") + + action._dict_to_xml( + { + "name": "falyx", + "metadata": {"version": "0.2.0"}, + "tags": ["cli", "framework"], + "commands": [{"name": "run"}, {"name": "help"}], + }, + root, + ) + + assert root.findtext("name") == "falyx" + assert root.find("metadata") is not None + assert root.find("metadata/version") is not None + assert root.findtext("metadata/version") == "0.2.0" + assert [element.text for element in root.findall("tags")] == ["cli", "framework"] + assert [element.findtext("name") for element in root.findall("commands")] == [ + "run", + "help", + ] + + +@pytest.mark.asyncio +async def test_save_file_requires_file_path_before_saving() -> None: + action = make_action(None, data="hello") + + with pytest.raises(ValueError, match="file_path must be set"): + await action.save_file("hello") + + +@pytest.mark.asyncio +async def test_save_file_refuses_to_overwrite_existing_file_when_disabled( + tmp_path: Path, +) -> None: + target = tmp_path / "existing.txt" + target.write_text("original", encoding="UTF-8") + action = make_action(target, overwrite=False) + + with pytest.raises(FileExistsError, match="File already exists"): + await action.save_file("replacement") + + assert target.read_text(encoding="UTF-8") == "original" + + +@pytest.mark.asyncio +async def test_save_file_requires_parent_directory_when_create_dirs_is_disabled( + tmp_path: Path, +) -> None: + target = tmp_path / "missing" / "out.txt" + action = make_action(target, create_dirs=False) + + with pytest.raises(FileNotFoundError, match="Directory does not exist"): + await action.save_file("hello") + + +@pytest.mark.asyncio +async def test_save_file_creates_missing_parent_directories(tmp_path: Path) -> None: + target = tmp_path / "nested" / "out.txt" + action = make_action(target, file_type=FileType.TEXT, create_dirs=True) + + await action.save_file("hello") + + assert target.read_text(encoding="UTF-8") == "hello" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("file_type", "filename", "data"), + [ + (FileType.TEXT, "note.txt", "hello"), + (FileType.JSON, "data.json", {"name": "falyx", "count": 2}), + (FileType.YAML, "data.yaml", {"name": "falyx", "enabled": True}), + (FileType.TOML, "data.toml", {"name": "falyx", "count": 2}), + (FileType.CSV, "rows.csv", [["name", "count"], ["falyx", "2"]]), + (FileType.TSV, "rows.tsv", [["name", "count"], ["falyx", "2"]]), + ( + FileType.XML, + "data.xml", + { + "name": "falyx", + "metadata": {"version": "0.2.0"}, + "tags": ["cli", "framework"], + }, + ), + ], +) +async def test_save_file_writes_supported_file_types( + tmp_path: Path, + file_type: FileType, + filename: str, + data: Any, +) -> None: + target = tmp_path / filename + action = make_action(target, file_type=file_type) + + await action.save_file(data) + + if file_type == FileType.TEXT: + assert target.read_text(encoding="UTF-8") == data + elif file_type == FileType.JSON: + assert json.loads(target.read_text(encoding="UTF-8")) == data + elif file_type == FileType.YAML: + assert yaml.safe_load(target.read_text(encoding="UTF-8")) == data + elif file_type == FileType.TOML: + assert toml.loads(target.read_text(encoding="UTF-8")) == data + elif file_type == FileType.CSV: + with target.open(newline="", encoding="UTF-8") as file: + assert list(csv.reader(file)) == data + elif file_type == FileType.TSV: + with target.open(newline="", encoding="UTF-8") as file: + assert list(csv.reader(file, delimiter="\t")) == data + elif file_type == FileType.XML: + root = ET.parse(target).getroot() + assert root.tag == "root" + assert root.findtext("name") == "falyx" + assert root.findtext("metadata/version") == "0.2.0" + assert [element.text for element in root.findall("tags")] == [ + "cli", + "framework", + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("file_type", [FileType.CSV, FileType.TSV]) +@pytest.mark.parametrize( + "data", + [ + {"name": "falyx"}, + ["name", "count"], + [["name", "count"], "not-a-row"], + ], +) +async def test_save_file_requires_list_of_lists_for_delimited_formats( + tmp_path: Path, + file_type: FileType, + data: Any, +) -> None: + target = tmp_path / "rows.data" + action = make_action(target, file_type=file_type) + + with pytest.raises(ValueError, match="requires a list of lists"): + await action.save_file(data) + + +@pytest.mark.asyncio +async def test_save_file_requires_dict_for_xml(tmp_path: Path) -> None: + target = tmp_path / "data.xml" + action = make_action(target, file_type=FileType.XML) + + with pytest.raises( + ValueError, match="XML file type requires data to be a dictionary" + ): + await action.save_file(["not", "a", "dict"]) + + +@pytest.mark.asyncio +async def test_save_file_raises_for_unsupported_internal_file_type( + tmp_path: Path, +) -> None: + target = tmp_path / "data.out" + action = make_action(target, file_type=FileType.TEXT) + action._file_type = object() # Force the defensive unsupported-type branch. + + with pytest.raises(ValueError, match="Unsupported file type"): + await action.save_file("hello") + + +@pytest.mark.asyncio +async def test_save_file_reraises_write_errors( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + target = tmp_path / "out.txt" + action = make_action(target, file_type=FileType.TEXT) + + def fake_write_text(self: Path, data: str, *, encoding: str | None = None) -> int: + raise OSError("disk is unavailable") + + monkeypatch.setattr(Path, "write_text", fake_write_text) + + with pytest.raises(OSError, match="disk is unavailable"): + await action.save_file("hello") + + +@pytest.mark.asyncio +async def test_run_saves_configured_data_and_triggers_success_lifecycle( + tmp_path: Path, +) -> None: + target = tmp_path / "out.txt" + action = make_action(target, file_type=FileType.TEXT, data="hello") + calls = register_lifecycle_hooks(action) + + result = await action("positional", ignored="kwarg") + + assert result == str(target) + assert target.read_text(encoding="UTF-8") == "hello" + assert hook_types(calls) == [ + HookType.BEFORE, + HookType.ON_SUCCESS, + HookType.AFTER, + HookType.ON_TEARDOWN, + ] + assert calls[0][1].args == ("positional",) + assert calls[0][1].kwargs == {"ignored": "kwarg"} + assert calls[0][1].action is action + + +@pytest.mark.asyncio +async def test_run_uses_data_from_kwargs_when_no_static_data_is_configured( + tmp_path: Path, +) -> None: + target = tmp_path / "out.txt" + action = make_action(target, file_type=FileType.TEXT, data=None) + + result = await action(data="from kwargs") + + assert result == str(target) + assert target.read_text(encoding="UTF-8") == "from kwargs" + + +@pytest.mark.asyncio +async def test_run_triggers_error_lifecycle_and_reraises(tmp_path: Path) -> None: + action = make_action(None, data="hello") + calls = register_lifecycle_hooks(action) + + with pytest.raises(ValueError, match="file_path must be set"): + await action() + + assert hook_types(calls) == [ + HookType.BEFORE, + HookType.ON_ERROR, + HookType.AFTER, + HookType.ON_TEARDOWN, + ] + assert isinstance(calls[1][1].exception, ValueError) + + +@pytest.mark.asyncio +async def test_preview_prints_tree_for_existing_file_when_overwrite_enabled( + tmp_path: Path, +) -> None: + target = tmp_path / "out.txt" + target.write_text("existing", encoding="UTF-8") + action = make_action(target, file_type=FileType.TEXT, overwrite=True) + action.console = CaptureConsole() + + await action.preview() + + assert len(action.console.printed) == 1 + printed_tree = action.console.printed[0][0][0] + assert isinstance(printed_tree, Tree) + + +@pytest.mark.asyncio +async def test_preview_prints_tree_for_existing_file_when_overwrite_disabled( + tmp_path: Path, +) -> None: + target = tmp_path / "out.txt" + target.write_text("existing", encoding="UTF-8") + action = make_action(target, file_type=FileType.TEXT, overwrite=False) + action.console = CaptureConsole() + + await action.preview() + + assert len(action.console.printed) == 1 + printed_tree = action.console.printed[0][0][0] + assert isinstance(printed_tree, Tree) + + +@pytest.mark.asyncio +async def test_preview_adds_to_existing_parent_without_printing(tmp_path: Path) -> None: + target = tmp_path / "out.txt" + action = make_action(target, file_type=FileType.JSON) + action.console = CaptureConsole() + parent = Tree("root") + + await action.preview(parent=parent) + + assert action.console.printed == [] + assert len(parent.children) == 1 + + +def test_clone_preserves_configuration_but_returns_distinct_action( + tmp_path: Path, +) -> None: + target = tmp_path / "out.json" + action = make_action( + target, + file_type=FileType.JSON, + mode="a", + encoding="utf-8", + data={"name": "falyx"}, + overwrite=False, + create_dirs=False, + inject_last_result=True, + inject_into="payload", + never_prompt=True, + ) + + clone = action.clone() + + assert clone is not action + assert clone.name == action.name + assert clone.file_path == action.file_path + assert clone.file_type == action.file_type + assert clone.mode == action.mode + assert clone.encoding == action.encoding + assert clone.data == action.data + assert clone.overwrite is action.overwrite + assert clone.create_dirs is action.create_dirs + assert clone.inject_last_result is action.inject_last_result + assert clone.inject_into == action.inject_into + assert clone.local_never_prompt is True diff --git a/tests/test_actions/test_selection_action.py b/tests/test_actions/test_selection_action.py index 3afd89a..39b17aa 100644 --- a/tests/test_actions/test_selection_action.py +++ b/tests/test_actions/test_selection_action.py @@ -1,7 +1,83 @@ -import pytest +from __future__ import annotations -from falyx.action import SelectionAction -from falyx.selection import SelectionOption +from typing import Any + +import pytest +from rich.tree import Tree + +import falyx.action.selection_action as selection_action_module +from falyx.action.action_types import SelectionReturnType +from falyx.action.selection_action import SelectionAction +from falyx.hook_manager import HookType +from falyx.selection import SelectionOption, SelectionOptionMap +from falyx.signals import CancelSignal + + +class DummyPromptSession: + pass + + +class CaptureConsole: + def __init__(self) -> None: + self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] + + def print(self, *args: Any, **kwargs: Any) -> None: + self.printed.append((args, kwargs)) + + +class FakeSharedContext: + def __init__(self, value: Any) -> None: + self.value = value + + def last_result(self) -> Any: + return self.value + + +class SizedButUnsupportedSelections: + def __len__(self) -> int: + return 0 + + +def make_action(selections: Any | None = None, **overrides: Any) -> SelectionAction: + defaults: dict[str, Any] = { + "name": "ChooseThing", + "selections": ( + selections if selections is not None else ["alpha", "beta", "gamma"] + ), + "prompt_session": DummyPromptSession(), + } + defaults.update(overrides) + return SelectionAction(**defaults) + + +def make_option_map_action(**overrides: Any) -> SelectionAction: + return make_action( + { + "0": SelectionOption("Development", "dev"), + "1": SelectionOption("Production", "prod"), + "2": SelectionOption("Staging", "stage"), + }, + **overrides, + ) + + +def register_lifecycle_hooks(action: SelectionAction) -> list[tuple[HookType, Any]]: + calls: list[tuple[HookType, Any]] = [] + + def make_hook(hook_type: HookType): + def hook(context: Any) -> None: + calls.append((hook_type, context)) + + return hook + + for hook_type in HookType: + action.hooks.register(hook_type, make_hook(hook_type)) + + return calls + + +def hook_types(calls: list[tuple[HookType, Any]]) -> list[HookType]: + return [hook_type for hook_type, _ in calls] @pytest.mark.asyncio @@ -285,3 +361,586 @@ async def test_selection_prompt_map_never_prompt_by_value_wildcard(): result = await action() assert result == ["Beta Service", "Alpha Service"] + + +def test_init_normalizes_list_tuple_set_and_basic_configuration() -> None: + session = DummyPromptSession() + + tuple_action = SelectionAction( + name="TupleChoice", + selections=("red", "blue"), + title="Colors", + columns=2, + prompt_message="[bold]Pick >[/] ", + default_selection="1", + number_selections=1, + separator=";", + allow_duplicates=True, + return_type="value", + prompt_session=session, + never_prompt=True, + show_table=False, + ) + + assert tuple_action.selections == ["red", "blue"] + assert tuple_action.return_type is SelectionReturnType.VALUE + assert tuple_action.title == "Colors" + assert tuple_action.columns == 2 + assert tuple_action.default_selection == "1" + assert tuple_action.separator == ";" + assert tuple_action.allow_duplicates is True + assert tuple_action.prompt_session is session + assert tuple_action.local_never_prompt is True + assert tuple_action.show_table is False + + set_action = make_action({"red", "blue"}) + assert sorted(set_action.selections) == ["blue", "red"] + + +def test_init_converts_plain_dict_to_selection_option_map() -> None: + action = make_action({"dev": "Development", "prod": "Production"}) + + assert isinstance(action.selections, SelectionOptionMap) + assert list(action.selections) == ["0", "1"] + assert action.selections["0"] == SelectionOption("dev", "Development") + assert action.selections["1"] == SelectionOption("prod", "Production") + + +def test_init_preserves_selection_option_map_values() -> None: + action = make_action( + { + "D": SelectionOption("Development", "dev", style="green"), + "P": SelectionOption("Production", "prod", style="red"), + } + ) + + assert isinstance(action.selections, SelectionOptionMap) + assert action.selections["D"].description == "Development" + assert action.selections["P"].value == "prod" + + +@pytest.mark.parametrize("number_selections", [1, 2, "*"]) +def test_number_selections_accepts_positive_ints_and_star( + number_selections: int | str, +) -> None: + action = make_action(number_selections=number_selections) + + assert action.number_selections == number_selections + + +@pytest.mark.parametrize("number_selections", [0, -1, "many", object()]) +def test_number_selections_rejects_invalid_values(number_selections: Any) -> None: + action = make_action() + + with pytest.raises(ValueError, match="number_selections"): + action.number_selections = number_selections + + +@pytest.mark.parametrize( + ("selections", "error_type", "match"), + [ + ({1: SelectionOption("One", 1)}, ValueError, "Invalid dictionary format"), + (123, TypeError, "selections"), + ], +) +def test_selections_setter_rejects_invalid_inputs( + selections: Any, + error_type: type[BaseException], + match: str, +) -> None: + with pytest.raises(error_type, match=match): + make_action(selections) + + +def test_find_cancel_key_returns_numeric_gap_for_dict_and_next_index_for_list() -> None: + dict_action = make_action( + { + "0": SelectionOption("Zero", 0), + "2": SelectionOption("Two", 2), + } + ) + list_action = make_action(["zero", "one"]) + + assert dict_action._find_cancel_key() == "1" + assert list_action._find_cancel_key() == "2" + + +def test_cancel_key_setter_rejects_non_string_values() -> None: + action = make_action() + + with pytest.raises(TypeError, match="Cancel key must be a string"): + action.cancel_key = 1 # type: ignore[assignment] + + +def test_cancel_key_setter_rejects_existing_dict_key() -> None: + action = make_action({"A": SelectionOption("Alpha", "alpha")}) + + with pytest.raises( + ValueError, match="Cancel key cannot be one of the selection keys" + ): + action.cancel_key = "A" + + +@pytest.mark.parametrize("cancel_key", ["x", "3"]) +def test_cancel_key_setter_rejects_invalid_list_cancel_key(cancel_key: str) -> None: + action = make_action(["alpha", "beta"]) + + with pytest.raises(ValueError, match="cancel_key must be a digit"): + action.cancel_key = cancel_key + + +def test_cancel_formatter_marks_cancel_key_and_formats_regular_items() -> None: + action = make_action(["alpha", "beta"]) + action.cancel_key = "2" + + assert "Cancel" in action.cancel_formatter(2, "Cancel") + assert action.cancel_formatter(1, "beta").endswith("beta") + + +def test_get_infer_target_disables_signature_inference() -> None: + action = make_action() + + assert action.get_infer_target() == (None, None) + + +@pytest.mark.parametrize( + ("return_type", "keys", "expected"), + [ + (SelectionReturnType.KEY, "0", "0"), + (SelectionReturnType.KEY, ["0", "2"], ["0", "2"]), + (SelectionReturnType.VALUE, "1", "prod"), + (SelectionReturnType.VALUE, ["0", "2"], ["dev", "stage"]), + (SelectionReturnType.DESCRIPTION, "0", "Development"), + ( + SelectionReturnType.DESCRIPTION, + ["0", "2"], + ["Development", "Staging"], + ), + ( + SelectionReturnType.DESCRIPTION_VALUE, + "1", + {"Production": "prod"}, + ), + ( + SelectionReturnType.DESCRIPTION_VALUE, + ["0", "2"], + {"Development": "dev", "Staging": "stage"}, + ), + ], +) +def test_get_result_from_keys_returns_configured_shape( + return_type: SelectionReturnType, + keys: str | list[str], + expected: Any, +) -> None: + action = make_option_map_action(return_type=return_type) + + assert action._get_result_from_keys(keys) == expected + + +@pytest.mark.parametrize("keys", ["0", ["0", "1"]]) +def test_get_result_from_keys_returns_items_mapping(keys: str | list[str]) -> None: + action = make_option_map_action(return_type=SelectionReturnType.ITEMS) + + result = action._get_result_from_keys(keys) + + assert isinstance(result, dict) + assert set(result) == ({keys} if isinstance(keys, str) else set(keys)) + assert all(isinstance(option, SelectionOption) for option in result.values()) + + +def test_get_result_from_keys_requires_dict_selections() -> None: + action = make_action(["alpha", "beta"]) + + with pytest.raises(TypeError, match="Selections must be a dictionary"): + action._get_result_from_keys("0") + + +def test_get_result_from_keys_rejects_unsupported_return_type() -> None: + action = make_option_map_action() + action.return_type = object() # Force defensive branch unreachable through __init__. + + with pytest.raises(ValueError, match="Unsupported return type"): + action._get_result_from_keys("0") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("maybe_result", "expected"), + [ + ("1", "1"), + ("prod", "1"), + ("Production", "1"), + ], +) +async def test_resolve_single_default_maps_dict_key_value_and_description( + maybe_result: str, + expected: str, +) -> None: + action = make_option_map_action() + + assert await action._resolve_single_default(maybe_result) == expected + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("maybe_result", "expected"), + [ + ("1", "1"), + ("beta", "1"), + ("missing", ""), + ], +) +async def test_resolve_single_default_maps_list_index_or_value( + maybe_result: str, + expected: str, +) -> None: + action = make_action(["alpha", "beta"]) + + assert await action._resolve_single_default(maybe_result) == expected + + +@pytest.mark.asyncio +async def test_resolve_effective_default_uses_first_value_for_single_selection_defaults() -> ( + None +): + action = make_action(["alpha", "beta"], default_selection=["beta"]) + + assert await action._resolve_effective_default() == "1" + + +@pytest.mark.asyncio +async def test_resolve_effective_default_uses_first_last_result_for_single_selection() -> ( + None +): + action = make_action(["alpha", "beta"]) + action.shared_context = FakeSharedContext(["beta"]) + + assert await action._resolve_effective_default() == "1" + + +@pytest.mark.asyncio +async def test_resolve_effective_default_joins_multi_selection_defaults() -> None: + action = make_action( + ["alpha", "beta", "gamma"], + default_selection=["alpha", "gamma"], + number_selections=2, + ) + + assert await action._resolve_effective_default() == "0,2" + + +@pytest.mark.asyncio +async def test_resolve_effective_default_joins_multi_selection_last_result() -> None: + action = make_action(["alpha", "beta", "gamma"], number_selections=2) + action.shared_context = FakeSharedContext(["alpha", "gamma"]) + + assert await action._resolve_effective_default() == "0,2" + + +@pytest.mark.asyncio +async def test_resolve_effective_default_allows_unbounded_multi_selection_last_result() -> ( + None +): + action = make_action(["alpha", "beta", "gamma"], number_selections="*") + action.shared_context = FakeSharedContext(["alpha", "beta", "gamma"]) + + assert await action._resolve_effective_default() == "0,1,2" + + +@pytest.mark.asyncio +async def test_resolve_effective_default_rejects_default_length_mismatch() -> None: + action = make_action( + ["alpha", "beta", "gamma"], + default_selection=["alpha"], + number_selections=2, + ) + + with pytest.raises(ValueError, match="default_selection has a different length"): + await action._resolve_effective_default() + + +@pytest.mark.asyncio +async def test_resolve_effective_default_rejects_last_result_length_mismatch() -> None: + action = make_action(["alpha", "beta", "gamma"], number_selections=2) + action.shared_context = FakeSharedContext(["alpha"]) + + with pytest.raises(ValueError, match="last_result has a different length"): + await action._resolve_effective_default() + + +@pytest.mark.asyncio +async def test_resolve_effective_default_warns_when_injected_result_is_unusable( + caplog: pytest.LogCaptureFixture, +) -> None: + action = make_action( + ["alpha", "beta"], + inject_last_result=True, + number_selections=2, + ) + action.shared_context = FakeSharedContext("missing") + + assert await action._resolve_effective_default() == "" + assert "Injected last result" in caplog.text + + +@pytest.mark.asyncio +async def test_run_list_headless_single_selection_uses_default() -> None: + action = make_action(["alpha", "beta"], never_prompt=True, default_selection="1") + + result = await action() + + assert result == "beta" + + +@pytest.mark.asyncio +async def test_run_list_headless_multi_selection_uses_default_list() -> None: + action = make_action( + ["alpha", "beta", "gamma"], + never_prompt=True, + default_selection=["alpha", "gamma"], + number_selections=2, + ) + + result = await action() + + assert result == ["alpha", "gamma"] + + +@pytest.mark.asyncio +async def test_run_dict_headless_single_selection_returns_value() -> None: + action = make_option_map_action(never_prompt=True, default_selection="1") + + result = await action() + + assert result == "prod" + + +@pytest.mark.asyncio +async def test_run_dict_headless_multi_selection_returns_configured_shape() -> None: + action = make_option_map_action( + never_prompt=True, + default_selection=["0", "2"], + number_selections=2, + return_type=SelectionReturnType.DESCRIPTION_VALUE, + ) + + result = await action() + + assert result == {"Development": "dev", "Staging": "stage"} + + +@pytest.mark.asyncio +async def test_run_list_interactive_uses_prompt_for_index( + monkeypatch: pytest.MonkeyPatch, +) -> None: + action = make_action(["alpha", "beta"], never_prompt=False, show_table=False) + + async def fake_prompt_for_index(*args: Any, **kwargs: Any) -> int: + assert kwargs["prompt_session"] is action.prompt_session + assert kwargs["show_table"] is False + assert kwargs["cancel_key"] == "2" + return 1 + + monkeypatch.setattr( + selection_action_module, "prompt_for_index", fake_prompt_for_index + ) + + result = await action() + + assert result == "beta" + + +@pytest.mark.asyncio +async def test_run_dict_interactive_uses_prompt_for_selection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + action = make_option_map_action(never_prompt=False, show_table=False) + + async def fake_prompt_for_selection(*args: Any, **kwargs: Any) -> str: + assert kwargs["prompt_session"] is action.prompt_session + assert kwargs["show_table"] is False + assert kwargs["cancel_key"] == "3" + return "2" + + monkeypatch.setattr( + selection_action_module, + "prompt_for_selection", + fake_prompt_for_selection, + ) + + result = await action() + + assert result == "stage" + + +@pytest.mark.asyncio +async def test_run_raises_when_never_prompt_has_no_effective_default() -> None: + action = make_action(["alpha", "beta"], never_prompt=True) + + with pytest.raises(ValueError, match="never_prompt"): + await action() + + +@pytest.mark.asyncio +async def test_run_list_cancel_triggers_error_and_teardown_hooks( + monkeypatch: pytest.MonkeyPatch, +) -> None: + action = make_action(["alpha", "beta"], never_prompt=True, default_selection="0") + calls = register_lifecycle_hooks(action) + + async def fake_resolve_effective_default() -> str: + return "4" + + monkeypatch.setattr( + action, "_resolve_effective_default", fake_resolve_effective_default + ) + + with pytest.raises(IndexError): + await action() + + assert HookType.BEFORE in hook_types(calls) + assert HookType.ON_ERROR in hook_types(calls) + assert HookType.AFTER in hook_types(calls) + assert HookType.ON_TEARDOWN in hook_types(calls) + error_contexts = [ + context for hook_type, context in calls if hook_type is HookType.ON_ERROR + ] + assert isinstance(error_contexts[0].exception, IndexError) + + +@pytest.mark.asyncio +async def test_run_dict_cancel_triggers_cancel_signal( + monkeypatch: pytest.MonkeyPatch, +) -> None: + action = make_option_map_action(never_prompt=True, default_selection="0") + + async def fake_resolve_effective_default() -> str: + return "3" + + monkeypatch.setattr( + action, "_resolve_effective_default", fake_resolve_effective_default + ) + + with pytest.raises(CancelSignal): + await action() + + +@pytest.mark.asyncio +async def test_run_unsupported_selection_storage_triggers_error_lifecycle( + monkeypatch: pytest.MonkeyPatch, +) -> None: + action = make_action(["alpha"], never_prompt=False) + action._selections = SizedButUnsupportedSelections() # type: ignore[assignment] + calls = register_lifecycle_hooks(action) + + async def fake_resolve_effective_default() -> str: + return "" + + monkeypatch.setattr( + action, "_resolve_effective_default", fake_resolve_effective_default + ) + + with pytest.raises(TypeError, match="selections"): + await action() + + assert HookType.ON_ERROR in hook_types(calls) + error_contexts = [ + context for hook_type, context in calls if hook_type is HookType.ON_ERROR + ] + assert isinstance(error_contexts[0].exception, TypeError) + + +@pytest.mark.asyncio +async def test_run_success_triggers_success_after_and_teardown_hooks() -> None: + action = make_action(["alpha", "beta"], never_prompt=True, default_selection="0") + calls = register_lifecycle_hooks(action) + + result = await action() + + assert result == "alpha" + assert hook_types(calls).count(HookType.BEFORE) == 1 + assert hook_types(calls).count(HookType.ON_SUCCESS) == 1 + assert hook_types(calls).count(HookType.AFTER) == 1 + assert hook_types(calls).count(HookType.ON_TEARDOWN) == 1 + success_contexts = [ + context for hook_type, context in calls if hook_type is HookType.ON_SUCCESS + ] + assert success_contexts[0].result == "alpha" + + +@pytest.mark.asyncio +async def test_preview_prints_tree_when_no_parent() -> None: + action = make_option_map_action(default_selection="1", never_prompt=True) + console = CaptureConsole() + action.console = console # type: ignore[assignment] + + await action.preview() + + assert len(console.printed) == 1 + assert "SelectionAction" in str(console.printed[0][0][0].label) + + +@pytest.mark.asyncio +async def test_preview_adds_to_parent_when_parent_is_provided() -> None: + action = make_action(["alpha", "beta"], default_selection="0") + parent = Tree("Root") + console = CaptureConsole() + action.console = console # type: ignore[assignment] + + await action.preview(parent=parent) + + assert console.printed == [] + assert len(parent.children) == 1 + assert "SelectionAction" in str(parent.children[0].label) + + +def test_str_includes_action_configuration() -> None: + action = make_action(["alpha", "beta"], return_type=SelectionReturnType.KEY) + + text = str(action) + + assert "SelectionAction" in text + assert "ChooseThing" in text + assert "KEY" in text or "key" in text + + +def test_clone_copies_selection_action_configuration() -> None: + session = DummyPromptSession() + action = SelectionAction( + name="CloneMe", + selections={"A": SelectionOption("Alpha", "alpha", style="green")}, + title="Letters", + columns=3, + prompt_message="Choose letter > ", + default_selection="A", + number_selections="*", + separator=";", + allow_duplicates=True, + inject_last_result=True, + inject_into="choice", + return_type=SelectionReturnType.DESCRIPTION, + prompt_session=session, + never_prompt=True, + show_table=False, + ) + + clone = action.clone() + + assert clone is not action + assert clone.name == action.name + assert clone.title == action.title + assert clone.columns == action.columns + assert clone.prompt_message == action.prompt_message + assert clone.default_selection == action.default_selection + assert clone.number_selections == action.number_selections + assert clone.separator == action.separator + assert clone.allow_duplicates == action.allow_duplicates + assert clone.inject_last_result is True + assert clone.inject_into == "choice" + assert clone.return_type is SelectionReturnType.DESCRIPTION + assert clone.prompt_session is session + assert clone.local_never_prompt is True + assert clone.show_table is False + assert clone.selections is not action.selections + assert clone.selections["A"].description == "Alpha" diff --git a/tests/test_actions/test_selection_file_action.py b/tests/test_actions/test_selection_file_action.py new file mode 100644 index 0000000..0989cbf --- /dev/null +++ b/tests/test_actions/test_selection_file_action.py @@ -0,0 +1,598 @@ +from __future__ import annotations + +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Any + +import pytest +import toml +import yaml +from rich.tree import Tree + +import falyx.action.select_file_action as select_file_module +from falyx.action.action_types import FileType +from falyx.action.select_file_action import SelectFileAction +from falyx.hook_manager import HookType +from falyx.selection import SelectionOption +from falyx.signals import CancelSignal + + +class DummyPromptSession: + pass + + +class CaptureConsole: + def __init__(self) -> None: + self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] + + def print(self, *args: Any, **kwargs: Any) -> None: + self.printed.append((args, kwargs)) + + +def make_action(directory: Path, **overrides: Any) -> SelectFileAction: + defaults: dict[str, Any] = { + "name": "ChooseFile", + "directory": directory, + "prompt_session": DummyPromptSession(), + } + defaults.update(overrides) + return SelectFileAction(**defaults) + + +def write_sample_files(directory: Path) -> dict[str, Path]: + paths = { + "text": directory / "note.txt", + "json": directory / "config.json", + "yaml": directory / "config.yaml", + "toml": directory / "config.toml", + "csv": directory / "rows.csv", + "tsv": directory / "rows.tsv", + "xml": directory / "doc.xml", + } + paths["text"].write_text("hello\n", encoding="UTF-8") + paths["json"].write_text('{"name": "falyx", "count": 2}', encoding="UTF-8") + paths["yaml"].write_text("name: falyx\nenabled: true\n", encoding="UTF-8") + paths["toml"].write_text('name = "falyx"\ncount = 2\n', encoding="UTF-8") + paths["csv"].write_text("name,count\nfalyx,2\n", encoding="UTF-8") + paths["tsv"].write_text("name\tcount\nfalyx\t2\n", encoding="UTF-8") + paths["xml"].write_text("falyx", encoding="UTF-8") + return paths + + +def register_lifecycle_hooks(action: SelectFileAction) -> list[tuple[HookType, Any]]: + calls: list[tuple[HookType, Any]] = [] + + def make_hook(hook_type: HookType): + def hook(context): + calls.append((hook_type, context)) + + return hook + + for hook_type in HookType: + action.hooks.register(hook_type, make_hook(hook_type)) + + return calls + + +def hook_types(calls: list[tuple[HookType, Any]]) -> list[HookType]: + return [hook_type for hook_type, _ in calls] + + +def test_init_normalizes_configuration_and_string_return_type(tmp_path: Path) -> None: + session = DummyPromptSession() + + action = SelectFileAction( + "ChooseConfig", + tmp_path, + title="Configs", + columns=4, + prompt_message="[bold]Pick >[/] ", + style="green", + suffix_filter=".json", + return_type="json", + encoding="utf-8", + number_selections="*", + separator=";", + allow_duplicates=True, + prompt_session=session, + never_prompt=True, + ) + + assert action.name == "ChooseConfig" + assert action.directory == tmp_path.resolve() + assert action.title == "Configs" + assert action.columns == 4 + assert action.suffix_filter == ".json" + assert action.return_type == FileType.JSON + assert action.encoding == "utf-8" + assert action.number_selections == "*" + assert action.separator == ";" + assert action.allow_duplicates is True + assert action.prompt_session is session + assert action.local_never_prompt is True + assert "ChooseConfig" in str(action) + assert ".json" in str(action) + + +@pytest.mark.parametrize("number_selections", [1, 2, "*"]) +def test_number_selections_accepts_positive_ints_and_star( + tmp_path: Path, + number_selections: int | str, +) -> None: + action = make_action(tmp_path, number_selections=number_selections) + + assert action.number_selections == number_selections + + +@pytest.mark.parametrize("number_selections", [0, -1, "many", object()]) +def test_number_selections_rejects_invalid_values( + tmp_path: Path, + number_selections: Any, +) -> None: + action = make_action(tmp_path) + + with pytest.raises(ValueError, match="number_selections"): + action.number_selections = number_selections + + +def test_get_options_uses_numeric_keys_and_selection_options(tmp_path: Path) -> None: + first = tmp_path / "a.txt" + second = tmp_path / "b.txt" + first.write_text("a", encoding="UTF-8") + second.write_text("b", encoding="UTF-8") + action = make_action(tmp_path, style="cyan") + + options = action.get_options([first, second]) + + assert list(options) == ["0", "1"] + assert options["0"] == SelectionOption( + description="a.txt", + value=first, + style="cyan", + ) + assert options["1"].description == "b.txt" + assert options["1"].value == second + + +def test_find_cancel_key_returns_first_numeric_gap_or_next_index(tmp_path: Path) -> None: + action = make_action(tmp_path) + + assert action._find_cancel_key({"0": object(), "2": object()}) == "1" + assert action._find_cancel_key({"0": object(), "1": object()}) == "2" + assert action._find_cancel_key({}) == "0" + + +def test_get_infer_target_disables_signature_inference(tmp_path: Path) -> None: + action = make_action(tmp_path) + + assert action.get_infer_target() == (None, None) + + +@pytest.mark.parametrize( + ("return_type", "file_key", "expected"), + [ + (FileType.TEXT, "text", "hello\n"), + (FileType.PATH, "text", "PATH"), + (FileType.JSON, "json", {"name": "falyx", "count": 2}), + (FileType.YAML, "yaml", {"name": "falyx", "enabled": True}), + (FileType.TOML, "toml", {"name": "falyx", "count": 2}), + (FileType.CSV, "csv", [["name", "count"], ["falyx", "2"]]), + (FileType.TSV, "tsv", [["name", "count"], ["falyx", "2"]]), + ], +) +def test_parse_file_returns_requested_representation( + tmp_path: Path, + return_type: FileType, + file_key: str, + expected: Any, +) -> None: + files = write_sample_files(tmp_path) + action = make_action(tmp_path, return_type=return_type) + + result = action.parse_file(files[file_key]) + + if expected == "PATH": + assert result == files[file_key] + else: + assert result == expected + + +def test_parse_file_returns_xml_root(tmp_path: Path) -> None: + files = write_sample_files(tmp_path) + action = make_action(tmp_path, return_type=FileType.XML) + + result = action.parse_file(files["xml"]) + + assert isinstance(result, ET.Element) + assert result.tag == "root" + assert result.findtext("name") == "falyx" + + +def test_clone_preserves_configuration_but_returns_distinct_action( + tmp_path: Path, +) -> None: + session = DummyPromptSession() + action = make_action( + tmp_path, + title="Pick a data file", + columns=2, + prompt_message="Select > ", + style="magenta", + suffix_filter=".json", + return_type=FileType.JSON, + encoding="utf-8", + number_selections=2, + separator="|", + allow_duplicates=True, + prompt_session=session, + never_prompt=True, + ) + + clone = action.clone() + + assert clone is not action + assert clone.name == action.name + assert clone.directory == action.directory + assert clone.title == action.title + assert clone.columns == action.columns + assert clone.prompt_message == action.prompt_message + assert clone.style == action.style + assert clone.suffix_filter == action.suffix_filter + assert clone.return_type == action.return_type + assert clone.encoding == action.encoding + assert clone.number_selections == action.number_selections + assert clone.separator == action.separator + assert clone.allow_duplicates == action.allow_duplicates + assert clone.prompt_session is session + assert clone.local_never_prompt is True + + +@pytest.mark.asyncio +async def test_preview_prints_tree_when_no_parent_is_given(tmp_path: Path) -> None: + write_sample_files(tmp_path) + action = make_action(tmp_path, suffix_filter=".json", return_type=FileType.JSON) + action.console = CaptureConsole() + + await action.preview() + + assert len(action.console.printed) == 1 + printed_tree = action.console.printed[0][0][0] + assert isinstance(printed_tree, Tree) + + +@pytest.mark.asyncio +async def test_preview_adds_to_existing_parent_and_limits_file_sample( + tmp_path: Path, +) -> None: + for index in range(12): + (tmp_path / f"config-{index}.json").write_text("{}", encoding="UTF-8") + (tmp_path / "ignore.txt").write_text("ignored", encoding="UTF-8") + action = make_action(tmp_path, suffix_filter=".json", return_type=FileType.JSON) + parent = Tree("root") + + await action.preview(parent=parent) + + assert len(parent.children) == 1 + action_tree = parent.children[0] + rendered_labels = [str(child.label) for child in action_tree.children] + assert any("Suffix filter" in label and ".json" in label for label in rendered_labels) + file_list = next( + child for child in action_tree.children if str(child.label) == "[dim]Files:[/]" + ) + assert len(file_list.children) == 11 + assert "... (2 more)" in str(file_list.children[-1].label) + + +@pytest.mark.asyncio +async def test_preview_reports_directory_scan_errors(tmp_path: Path) -> None: + missing_dir = tmp_path / "missing" + action = make_action(missing_dir) + parent = Tree("root") + + await action.preview(parent=parent) + + action_tree = parent.children[0] + assert any( + "Error scanning directory" in str(child.label) for child in action_tree.children + ) + + +@pytest.mark.asyncio +async def test_run_raises_for_missing_directory_and_triggers_error_lifecycle( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + action = make_action(tmp_path / "missing") + calls = register_lifecycle_hooks(action) + recorded: list[Any] = [] + monkeypatch.setattr(select_file_module.er, "record", recorded.append) + + with pytest.raises(FileNotFoundError, match="does not exist"): + await action("arg", flag=True) + + assert hook_types(calls) == [ + HookType.BEFORE, + HookType.ON_ERROR, + HookType.AFTER, + HookType.ON_TEARDOWN, + ] + assert recorded + assert isinstance(recorded[0].exception, FileNotFoundError) + assert recorded[0].args == ("arg",) + assert recorded[0].kwargs == {"flag": True} + + +@pytest.mark.asyncio +async def test_run_raises_when_directory_path_is_file( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + directory_path = tmp_path / "not-a-dir.txt" + directory_path.write_text("not a directory", encoding="UTF-8") + action = make_action(directory_path) + calls = register_lifecycle_hooks(action) + monkeypatch.setattr(select_file_module.er, "record", lambda context: None) + + with pytest.raises(NotADirectoryError, match="is not a directory"): + await action() + + assert HookType.ON_ERROR in hook_types(calls) + + +@pytest.mark.asyncio +async def test_run_raises_when_suffix_filter_matches_no_files( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + (tmp_path / "note.txt").write_text("hello", encoding="UTF-8") + action = make_action(tmp_path, suffix_filter=".json") + calls = register_lifecycle_hooks(action) + monkeypatch.setattr(select_file_module.er, "record", lambda context: None) + + with pytest.raises(FileNotFoundError, match="No files found"): + await action() + + assert HookType.ON_ERROR in hook_types(calls) + + +@pytest.mark.asyncio +async def test_run_single_selection_returns_parsed_file_and_passes_prompt_options( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + selected = tmp_path / "note.txt" + selected.write_text("selected", encoding="UTF-8") + (tmp_path / "other.json").write_text("{}", encoding="UTF-8") + action = make_action( + tmp_path, + suffix_filter=".txt", + return_type=FileType.TEXT, + number_selections=1, + separator=";", + allow_duplicates=True, + ) + calls = register_lifecycle_hooks(action) + recorded: list[Any] = [] + prompt_calls: list[dict[str, Any]] = [] + render_calls: list[dict[str, Any]] = [] + monkeypatch.setattr(select_file_module.er, "record", recorded.append) + + def fake_render_selection_dict_table(**kwargs: Any) -> object: + render_calls.append(kwargs) + return object() + + async def fake_prompt_for_selection(valid_keys, table, **kwargs: Any) -> str: + prompt_calls.append({"valid_keys": list(valid_keys), "table": table, **kwargs}) + return "0" + + monkeypatch.setattr( + select_file_module, + "render_selection_dict_table", + fake_render_selection_dict_table, + ) + monkeypatch.setattr( + select_file_module, + "prompt_for_selection", + fake_prompt_for_selection, + ) + + result = await action() + + assert result == "selected" + assert hook_types(calls) == [ + HookType.BEFORE, + HookType.ON_SUCCESS, + HookType.AFTER, + HookType.ON_TEARDOWN, + ] + assert recorded[0].result == "selected" + assert render_calls[0]["title"] == action.title + assert render_calls[0]["columns"] == action.columns + assert set(render_calls[0]["selections"]) == {"0", "1"} + assert prompt_calls[0]["valid_keys"] == ["0", "1"] + assert prompt_calls[0]["prompt_session"] is action.prompt_session + assert prompt_calls[0]["prompt_message"] == action.prompt_message + assert prompt_calls[0]["number_selections"] == 1 + assert prompt_calls[0]["separator"] == ";" + assert prompt_calls[0]["allow_duplicates"] is True + assert prompt_calls[0]["cancel_key"] == "1" + + +@pytest.mark.asyncio +async def test_run_multi_selection_returns_results_for_each_selected_file( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + first = tmp_path / "a.txt" + second = tmp_path / "b.txt" + first.write_text("a", encoding="UTF-8") + second.write_text("b", encoding="UTF-8") + action = make_action(tmp_path, return_type=FileType.PATH, number_selections=2) + monkeypatch.setattr(select_file_module.er, "record", lambda context: None) + monkeypatch.setattr( + select_file_module, + "render_selection_dict_table", + lambda **kwargs: object(), + ) + + async def fake_prompt_for_selection(*args: Any, **kwargs: Any) -> list[str]: + return ["0", "1"] + + monkeypatch.setattr( + select_file_module, + "prompt_for_selection", + fake_prompt_for_selection, + ) + + result = await action() + + print(result) + + assert result == [first, second] or result == [second, first] + + +@pytest.mark.asyncio +async def test_run_cancel_selection_raises_cancel_signal_and_skips_error_hook( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + (tmp_path / "a.txt").write_text("a", encoding="UTF-8") + action = make_action(tmp_path) + calls = register_lifecycle_hooks(action) + recorded: list[Any] = [] + monkeypatch.setattr(select_file_module.er, "record", recorded.append) + monkeypatch.setattr( + select_file_module, + "render_selection_dict_table", + lambda **kwargs: object(), + ) + + async def fake_prompt_for_selection(*args: Any, **kwargs: Any) -> str: + return kwargs["cancel_key"] + + monkeypatch.setattr( + select_file_module, + "prompt_for_selection", + fake_prompt_for_selection, + ) + + with pytest.raises(CancelSignal, match="User canceled"): + await action() + + assert hook_types(calls) == [ + HookType.BEFORE, + HookType.AFTER, + HookType.ON_TEARDOWN, + ] + assert recorded + assert recorded[0].exception is None + + +def assert_parse_file_value_error( + action: SelectFileAction, + file: Path, + *, + expected_cause_type: ( + type[BaseException] | tuple[type[BaseException], ...] | None + ) = None, +) -> ValueError: + with pytest.raises(ValueError) as exc_info: + action.parse_file(file) + + error = exc_info.value + assert f"Failed to parse {file.name} as" in str(error) + assert error.__cause__ is not None + if expected_cause_type is not None: + assert isinstance(error.__cause__, expected_cause_type) + return error + + +def test_parse_file_wraps_invalid_json_errors(tmp_path: Path) -> None: + import json + + broken = tmp_path / "broken.json" + broken.write_text('{"name": ', encoding="UTF-8") + action = make_action(tmp_path, return_type=FileType.JSON) + + assert_parse_file_value_error( + action, broken, expected_cause_type=json.JSONDecodeError + ) + + +def test_parse_file_wraps_invalid_toml_errors(tmp_path: Path) -> None: + broken = tmp_path / "broken.toml" + broken.write_text('name = "falyx"\ncount = ', encoding="UTF-8") + action = make_action(tmp_path, return_type=FileType.TOML) + + assert_parse_file_value_error( + action, broken, expected_cause_type=toml.TomlDecodeError + ) + + +def test_parse_file_wraps_invalid_yaml_errors(tmp_path: Path) -> None: + broken = tmp_path / "broken.yaml" + broken.write_text("name: [unterminated\n", encoding="UTF-8") + action = make_action(tmp_path, return_type=FileType.YAML) + + assert_parse_file_value_error(action, broken, expected_cause_type=yaml.YAMLError) + + +def test_parse_file_wraps_invalid_xml_errors(tmp_path: Path) -> None: + broken = tmp_path / "broken.xml" + broken.write_text("falyx", encoding="UTF-8") + action = make_action(tmp_path, return_type=FileType.XML) + + assert_parse_file_value_error(action, broken, expected_cause_type=ET.ParseError) + + +@pytest.mark.parametrize( + "return_type", + [ + FileType.TEXT, + FileType.JSON, + FileType.YAML, + FileType.TOML, + FileType.CSV, + FileType.TSV, + FileType.XML, + ], +) +def test_parse_file_wraps_missing_file_errors( + tmp_path: Path, return_type: FileType +) -> None: + missing = tmp_path / "missing.data" + action = make_action(tmp_path, return_type=return_type) + + assert_parse_file_value_error(action, missing, expected_cause_type=FileNotFoundError) + + +@pytest.mark.parametrize("return_type", [FileType.CSV, FileType.TSV]) +def test_parse_file_wraps_csv_style_open_errors( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + return_type: FileType, +) -> None: + data_file = tmp_path / "rows.data" + data_file.write_text("name,count\nfalyx,2\n", encoding="UTF-8") + action = make_action(tmp_path, return_type=return_type) + + def fake_open(*args: Any, **kwargs: Any) -> Any: + raise OSError("cannot open test file") + + monkeypatch.setattr("builtins.open", fake_open) + + error = assert_parse_file_value_error(action, data_file, expected_cause_type=OSError) + assert "cannot open test file" in str(error) + + +def test_parse_file_wraps_unsupported_return_type_errors(tmp_path: Path) -> None: + data_file = tmp_path / "note.txt" + data_file.write_text("hello", encoding="UTF-8") + action = make_action(tmp_path, return_type=FileType.TEXT) + action.return_type = object() # Force the defensive unsupported-type branch. + + error = assert_parse_file_value_error( + action, data_file, expected_cause_type=ValueError + ) + + assert "Unsupported return type" in str(error.__cause__) diff --git a/tests/test_command.py b/tests/test_command.py index 8056f37..83adad0 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -1,16 +1,89 @@ # test_command.py +import logging +from collections.abc import Callable +from types import SimpleNamespace +from typing import Any + import pytest from pydantic import ValidationError -from falyx.action import Action, BaseIOAction, ChainedAction +import falyx.command as command_module +from falyx.action import Action, BaseAction, BaseIOAction, ChainedAction from falyx.command import Command +from falyx.exceptions import CommandArgumentError, InvalidHookError, NotAFalyxError +from falyx.execution_option import ExecutionOption from falyx.execution_registry import ExecutionRegistry as er +from falyx.hook_manager import HookType +from falyx.parser.command_argument_parser import CommandArgumentParser from falyx.retry import RetryPolicy +from falyx.signals import CancelSignal asyncio_default_fixture_loop_scope = "function" -# --- Fixtures --- +class CaptureConsole: + def __init__(self) -> None: + self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] + + def print(self, *args: Any, **kwargs: Any) -> None: + self.printed.append((args, kwargs)) + + +class FakeBaseAction(BaseAction): + def __init__( + self, + name: str = "FakeAction", + *, + result: Any = "ok", + infer_target: Callable[..., Any] | None = None, + metadata: dict[str, Any] | None = None, + never_prompt: bool | None = None, + ) -> None: + super().__init__(name, never_prompt=never_prompt) + self.result = result + self.infer_target = infer_target or (lambda: None) + self.metadata = metadata + self.preview_calls = 0 + self.calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] + + async def _run(self, *args: Any, **kwargs: Any) -> Any: + self.calls.append((args, kwargs)) + return self.result + + async def preview(self, parent=None): + self.preview_calls += 1 + if parent is not None: + parent.add("fake preview") + return None + + def get_infer_target(self): + return self.infer_target, self.metadata + + def clone(self) -> "FakeBaseAction": + return FakeBaseAction( + self.name, + result=self.result, + infer_target=self.infer_target, + metadata=self.metadata, + never_prompt=self.local_never_prompt, + ) + + +def make_command(**overrides: Any) -> Command: + defaults = dict( + key="D", + description="Deploy command", + action=lambda *args, **kwargs: {"args": args, "kwargs": kwargs}, + auto_args=False, + ) + defaults.update(overrides) + return Command.build(**defaults) + + +def formatted_plain_text(formatted_text) -> str: + return "".join(fragment for _, fragment in list(formatted_text)) + + @pytest.fixture(autouse=True) def clean_registry(): er.clear() @@ -18,12 +91,10 @@ def clean_registry(): er.clear() -# --- Dummy Action --- async def dummy_action(): return "ok" -# --- Dummy IO Action --- class DummyInputAction(BaseIOAction): async def _run(self, *args, **kwargs): return "needs input" @@ -32,7 +103,6 @@ class DummyInputAction(BaseIOAction): pass -# --- Tests --- @pytest.mark.asyncio async def test_command_creation(): """Test if Command can be created with a callable.""" @@ -185,3 +255,642 @@ def test_command_bad_options_manager(): options_manager="not_a_dict_or_callable", ) assert "Input should be an instance of OptionsManager" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_resolve_args_uses_custom_parser_and_splits_string_input() -> None: + seen: list[list[str]] = [] + + def custom_parser(tokens: list[str]): + seen.append(tokens) + return (("parsed",), {"tokens": tokens}, {"summary": True}) + + command = make_command(custom_parser=custom_parser) + + args, kwargs, execution_args = await command.resolve_args("--name 'Ada Lovelace'") + + assert seen == [["--name", "Ada Lovelace"]] + assert args == ("parsed",) + assert kwargs == {"tokens": ["--name", "Ada Lovelace"]} + assert execution_args == {"summary": True} + + +@pytest.mark.asyncio +async def test_resolve_args_rejects_non_callable_custom_parser() -> None: + command = make_command(custom_parser=lambda tokens: ((), {}, {})) + command.custom_parser = object() + + with pytest.raises(NotAFalyxError, match="custom_parser must be a callable"): + await command.resolve_args([]) + + +@pytest.mark.asyncio +async def test_resolve_args_wraps_bad_shell_input_for_custom_parser() -> None: + command = make_command(custom_parser=lambda tokens: ((), {}, {})) + + with pytest.raises(CommandArgumentError, match="Failed to parse arguments"): + await command.resolve_args("'unterminated") + + +@pytest.mark.asyncio +async def test_resolve_args_wraps_bad_shell_input_for_command_argument_parser() -> None: + command = make_command() + + with pytest.raises(CommandArgumentError, match="Failed to parse arguments"): + await command.resolve_args("'unterminated") + + +@pytest.mark.asyncio +async def test_resolve_args_rejects_missing_parser_when_no_custom_parser_exists() -> None: + command = make_command(custom_parser=lambda tokens: ((), {}, {})) + command.custom_parser = None + command.arg_parser = None + + with pytest.raises(NotAFalyxError, match="Command has no parser configured"): + await command.resolve_args([]) + + +@pytest.mark.asyncio +async def test_resolve_args_rejects_invalid_arg_parser_instance() -> None: + command = make_command() + command.arg_parser = object() + + with pytest.raises(NotAFalyxError, match="arg_parser must be an instance"): + await command.resolve_args([]) + + +@pytest.mark.asyncio +async def test_explicit_argument_definitions_are_added_to_default_parser() -> None: + command = make_command( + arguments=[ + { + "flags": ("target",), + "help": "Deployment target", + }, + { + "flags": ("--region",), + "default": "us-east", + }, + ] + ) + + args, kwargs, execution_args = await command.resolve_args( + ["api", "--region", "us-west"] + ) + + assert args == ("api",) + assert kwargs == {"region": "us-west"} + assert execution_args == {} + + +@pytest.mark.asyncio +async def test_argument_config_callback_configures_existing_parser() -> None: + def configure(parser: CommandArgumentParser) -> None: + parser.add_argument("--region", default="us-east") + + command = make_command(argument_config=configure) + + args, kwargs, execution_args = await command.resolve_args(["--region", "us-west"]) + + assert args == () + assert kwargs == {"region": "us-west"} + assert execution_args == {} + + +def test_base_action_inference_merges_action_metadata() -> None: + def deploy(region: str) -> None: + return None + + action = FakeBaseAction( + infer_target=deploy, + metadata={"region": {"help": "Region from action metadata"}}, + ) + + command = Command.build( + key="D", + description="Deploy command", + action=action, + auto_args=True, + ) + + assert command.arg_metadata["region"] == {"help": "Region from action metadata"} + assert isinstance(command.arg_parser, CommandArgumentParser) + assert "region" in command.arg_parser._positional + + +def test_build_validates_parser_runtime_dependencies_and_retry_policy() -> None: + with pytest.raises(NotAFalyxError, match="arg_parser"): + make_command(arg_parser=object()) + + with pytest.raises(NotAFalyxError, match="options_manager"): + make_command(options_manager=object()) + + with pytest.raises(InvalidHookError, match="HookManager"): + make_command(hooks=object()) + + with pytest.raises(NotAFalyxError, match="retry_policy"): + make_command(retry_policy=object()) + + +def test_build_normalizes_execution_options_and_registers_hook_lists() -> None: + async def before(_context) -> None: + return None + + async def success(_context) -> None: + return None + + async def error(_context) -> None: + return None + + async def after(_context) -> None: + return None + + async def teardown(_context) -> None: + return None + + command = make_command( + execution_options=["summary", ExecutionOption.CONFIRM], + before_hooks=[before], + success_hooks=[success], + error_hooks=[error], + after_hooks=[after], + teardown_hooks=[teardown], + spinner=True, + ) + + assert ExecutionOption.SUMMARY in command.execution_options + assert ExecutionOption.CONFIRM in command.execution_options + assert before in command.hooks._hooks[HookType.BEFORE] + assert success in command.hooks._hooks[HookType.ON_SUCCESS] + assert error in command.hooks._hooks[HookType.ON_ERROR] + assert after in command.hooks._hooks[HookType.AFTER] + assert teardown in command.hooks._hooks[HookType.ON_TEARDOWN] + assert command.hooks._hooks[HookType.BEFORE] + assert command.hooks._hooks[HookType.ON_TEARDOWN] + + +def test_model_post_init_warns_for_retry_flags_on_plain_callable( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING): + make_command(retry=True, retry_all=True) + + assert "Retry requested" in caplog.text + assert "Retry all requested" in caplog.text + + +def test_retry_all_for_base_action_enables_policy_recursively( + monkeypatch: pytest.MonkeyPatch, +) -> None: + action = FakeBaseAction() + calls: list[tuple[BaseAction, RetryPolicy]] = [] + + def fake_enable_retries_recursively( + base_action: BaseAction, policy: RetryPolicy + ) -> None: + calls.append((base_action, policy)) + + monkeypatch.setattr( + command_module, + "enable_retries_recursively", + fake_enable_retries_recursively, + ) + + command = Command.build( + key="D", + description="Deploy command", + action=action, + retry_all=True, + auto_args=False, + ) + + assert command.retry_policy.enabled is True + assert calls == [(action, command.retry_policy)] + + +def test_logging_hooks_are_registered_on_base_action() -> None: + action = FakeBaseAction() + + Command.build( + key="D", + description="Deploy command", + action=action, + logging_hooks=True, + auto_args=False, + ) + + assert any(action.hooks._hooks.values()) + + +def test_ignore_in_history_is_copied_to_base_action() -> None: + action = FakeBaseAction() + + Command.build( + key="D", + description="Deploy command", + action=action, + ignore_in_history=True, + auto_args=False, + ) + + assert action.ignore_in_history is True + + +def test_retry_flag_enables_retry_on_action_instance() -> None: + action = Action("DeployAction", lambda: "ok") + + Command.build( + key="D", + description="Deploy command", + action=action, + retry=True, + auto_args=False, + ) + + assert action.retry_policy.enabled is True + + +def test_confirmation_prompt_uses_custom_message() -> None: + command = make_command(confirm_message="Ship it?") + + assert list(command._confirmation_prompt) == [("class:confirm", "Ship it?")] + + +def test_confirmation_prompt_describes_default_callable_with_static_inputs() -> None: + def deploy() -> str: + return "ok" + + command = Command.build( + key="D", + description="Deploy command", + action=deploy, + args=("api",), + kwargs={"region": "us-east"}, + auto_args=False, + ) + + plain_text = formatted_plain_text(command._confirmation_prompt) + + assert "Confirm execution of" in plain_text + assert "D" in plain_text + assert "Deploy command" in plain_text + assert "calls" in plain_text + assert "args=('api',)" in plain_text + assert "kwargs={'region': 'us-east'}" in plain_text + + +def test_confirmation_prompt_uses_base_action_name() -> None: + command = Command.build( + key="D", + description="Deploy command", + action=FakeBaseAction("DeployAction"), + auto_args=False, + ) + + assert "DeployAction" in formatted_plain_text(command._confirmation_prompt) + + +@pytest.mark.asyncio +async def test_confirmation_cancel_previews_then_raises_cancel_signal( + monkeypatch: pytest.MonkeyPatch, +) -> None: + command = make_command(confirm=True, preview_before_confirm=True) + previewed: list[str] = [] + confirmed_prompts: list[Any] = [] + + async def fake_preview(self: Command) -> None: + previewed.append(self.key) + + async def fake_confirm(prompt) -> bool: + confirmed_prompts.append(prompt) + return False + + monkeypatch.setattr(Command, "preview", fake_preview) + monkeypatch.setattr(command_module, "confirm_async", fake_confirm) + + with pytest.raises(CancelSignal, match="Cancelled by confirmation"): + await command() + + assert previewed == ["D"] + assert confirmed_prompts + + +@pytest.mark.asyncio +async def test_confirmation_accepts_and_executes_action( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[str] = [] + + async def fake_confirm(_prompt) -> bool: + return True + + def action() -> str: + calls.append("ran") + return "done" + + monkeypatch.setattr(command_module, "confirm_async", fake_confirm) + command = Command.build( + key="D", + description="Deploy command", + action=action, + confirm=True, + preview_before_confirm=False, + auto_args=False, + ) + + assert await command() == "done" + assert calls == ["ran"] + + +def test_get_option_returns_default_when_no_options_manager_is_available() -> None: + command = make_command() + command.options_manager = None + + assert command.get_option("missing", "fallback") == "fallback" + + +def test_primary_alias_falls_back_to_command_key() -> None: + assert make_command(aliases=[]).primary_alias == "D" + + +def test_usage_reports_no_arguments_when_parser_is_absent() -> None: + command = make_command(custom_parser=lambda tokens: ((), {}, {})) + + assert command.usage == "No arguments defined." + + +def test_usage_delegates_to_arg_parser_when_available() -> None: + command = make_command(aliases=["deploy"]) + + assert "D" in command.usage + assert "deploy" in command.usage + + +def test_help_signature_full_mode_includes_help_text_and_tags() -> None: + command = make_command(help_text="Detailed deploy help", tags=["deploy", "cloud"]) + + usage, description, tags = command.help_signature + + assert "D" in usage + assert "Detailed deploy help" in description + assert "deploy, cloud" in tags + + +def test_help_signature_simple_mode_uses_key_and_aliases() -> None: + command = make_command( + aliases=["deploy"], + help_text="Detailed deploy help", + simple_help_signature=True, + ) + + usage, description, tags = command.help_signature + + assert "D" in usage + assert "deploy" in usage + assert "Detailed deploy help" in description + assert tags == "" + + +def test_log_summary_delegates_to_existing_context() -> None: + command = make_command() + calls: list[str] = [] + command._context = SimpleNamespace(log_summary=lambda: calls.append("logged")) + + command.log_summary() + + assert calls == ["logged"] + + +def test_render_usage_prefers_custom_usage(monkeypatch: pytest.MonkeyPatch) -> None: + captured = CaptureConsole() + monkeypatch.setattr(command_module, "console", captured) + command = make_command(custom_usage=lambda: "custom usage") + + command.render_usage() + + assert captured.printed[0][0] == ("custom usage",) + + +def test_render_usage_falls_back_to_command_key_without_parser( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured = CaptureConsole() + monkeypatch.setattr(command_module, "console", captured) + command = make_command(custom_parser=lambda tokens: ((), {}, {})) + + command.render_usage() + + assert captured.printed[0][0] == ("[bold]usage:[/] D",) + + +def test_render_help_and_tldr_custom_renderers_return_true( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured = CaptureConsole() + monkeypatch.setattr(command_module, "console", captured) + command = make_command( + custom_help=lambda: "custom help", + custom_tldr=lambda: "custom tldr", + ) + + assert command.render_help() is True + assert command.render_tldr() is True + assert [printed[0][0] for printed in captured.printed] == [ + "custom help", + "custom tldr", + ] + + +def test_render_help_and_tldr_return_false_without_parser_or_custom_renderer() -> None: + command = make_command(custom_parser=lambda tokens: ((), {}, {})) + + assert command.render_help() is False + assert command.render_tldr() is False + + +@pytest.mark.asyncio +async def test_preview_renders_plain_callable_details( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured = CaptureConsole() + monkeypatch.setattr(command_module, "console", captured) + + def deploy() -> str: + return "ok" + + command = Command.build( + key="D", + description="Deploy command", + action=deploy, + args=("api",), + kwargs={"region": "us-east"}, + help_text="Preview help", + auto_args=False, + ) + + await command.preview() + + rendered = "\n".join(str(args[0]) for args, _ in captured.printed) + assert "Command:" in rendered + assert "Preview help" in rendered + assert "Would call:" in rendered + assert "args=('api',), kwargs={'region': 'us-east'}" in rendered + + +@pytest.mark.asyncio +async def test_preview_renders_base_action_tree(monkeypatch: pytest.MonkeyPatch) -> None: + captured = CaptureConsole() + monkeypatch.setattr(command_module, "console", captured) + action = FakeBaseAction("DeployAction") + command = Command.build( + key="D", + description="Deploy command", + action=action, + help_text="Preview help", + auto_args=False, + ) + + await command.preview() + + assert action.preview_calls == 1 + assert captured.printed + + +@pytest.mark.asyncio +async def test_call_merges_static_and_invocation_inputs_and_triggers_hooks() -> None: + events: list[tuple[str, Any]] = [] + + async def before(context) -> None: + events.append(("before", context.args)) + + async def success(context) -> None: + events.append(("success", context.result)) + + async def after(context) -> None: + events.append(("after", context.result)) + + async def teardown(context) -> None: + events.append(("teardown", context.result)) + + def action(*args: Any, **kwargs: Any) -> dict[str, Any]: + return {"args": args, "kwargs": kwargs} + + command = Command.build( + key="D", + description="Deploy command", + action=action, + args=("static",), + kwargs={"region": "us-east"}, + before_hooks=[before], + success_hooks=[success], + after_hooks=[after], + teardown_hooks=[teardown], + auto_args=False, + ) + + result = await command("runtime", region="us-west") + + assert result == { + "args": ("runtime", "static"), + "kwargs": {"region": "us-west"}, + } + assert command.result == result + assert events == [ + ("before", ("runtime", "static")), + ("success", result), + ("after", result), + ("teardown", result), + ] + + +@pytest.mark.asyncio +async def test_call_triggers_error_after_and_teardown_hooks_on_failure() -> None: + events: list[tuple[str, str | None]] = [] + + async def on_error(context) -> None: + events.append(("error", str(context.exception))) + + async def after(context) -> None: + events.append(("after", str(context.exception))) + + async def teardown(context) -> None: + events.append(("teardown", str(context.exception))) + + def action() -> None: + raise RuntimeError("boom") + + command = Command.build( + key="D", + description="Deploy command", + action=action, + error_hooks=[on_error], + after_hooks=[after], + teardown_hooks=[teardown], + auto_args=False, + ) + + with pytest.raises(RuntimeError, match="boom"): + await command() + + assert events == [ + ("error", "boom"), + ("after", "boom"), + ("teardown", "boom"), + ] + + +def test_str_includes_command_identity() -> None: + text = str(make_command()) + + assert "Command(key='D'" in text + assert "Deploy command" in text + + +def test_clone_with_overrides_clones_parser_hooks_and_base_action() -> None: + action = FakeBaseAction("DeployAction") + + async def before(_context) -> None: + return None + + command = Command.build( + key="D", + description="Deploy command", + action=action, + aliases=["deploy"], + before_hooks=[before], + auto_args=False, + ) + + clone = command.clone_with_overrides( + key="P", + description="Promote command", + aliases=["promote"], + ) + + assert clone.key == "P" + assert clone.description == "Promote command" + assert clone.aliases == ["promote"] + assert clone.action is not command.action + assert isinstance(clone.action, FakeBaseAction) + assert clone.hooks is not command.hooks + assert before in clone.hooks._hooks[HookType.BEFORE] + assert isinstance(clone.arg_parser, CommandArgumentParser) + assert clone.arg_parser.command_key == "P" + + +def test_clone_with_overrides_can_replace_action_and_execution_options() -> None: + command = make_command(execution_options=["summary"]) + + def replacement() -> str: + return "replacement" + + clone = command.clone_with_overrides( + action=replacement, + execution_options=[ExecutionOption.CONFIRM], + simple_help_signature=True, + ) + + assert clone.action is not command.action + assert ExecutionOption.CONFIRM in clone.execution_options + assert ExecutionOption.SUMMARY not in clone.execution_options + assert clone.simple_help_signature is True diff --git a/tests/test_context.py b/tests/test_context.py new file mode 100644 index 0000000..a58e8b2 --- /dev/null +++ b/tests/test_context.py @@ -0,0 +1,341 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +import pytest +from rich.console import Console + +from falyx.context import ExecutionContext, InvocationContext, SharedContext +from falyx.mode import FalyxMode + + +class DummyAction: + def __init__(self, name: str = "DummyAction") -> None: + self.name = name + + def __str__(self) -> str: + return self.name + + +def make_execution_context(**overrides: Any) -> ExecutionContext: + defaults: dict[str, Any] = { + "name": "Build", + "action": DummyAction("build"), + } + defaults.update(overrides) + return ExecutionContext(**defaults) + + +def make_shared_context(**overrides: Any) -> SharedContext: + defaults: dict[str, Any] = { + "name": "Workflow", + "action": DummyAction("workflow"), + } + defaults.update(overrides) + return SharedContext(**defaults) + + +def test_execution_context_get_shared_context_returns_existing_context() -> None: + shared = make_shared_context() + context = make_execution_context(shared_context=shared) + + assert context.get_shared_context() is shared + + +def test_execution_context_get_shared_context_raises_when_missing() -> None: + context = make_execution_context() + + with pytest.raises(ValueError, match="SharedContext is not set"): + context.get_shared_context() + + +def test_execution_context_duration_handles_not_started_running_and_stopped( + monkeypatch: pytest.MonkeyPatch, +) -> None: + context = make_execution_context() + assert context.duration is None + + context.start_time = 10.0 + context.end_time = None + monkeypatch.setattr("falyx.context.time.perf_counter", lambda: 12.5) + assert context.duration == pytest.approx(2.5) + + context.end_time = 14.0 + assert context.duration == pytest.approx(4.0) + + +def test_execution_context_start_and_stop_timer_populate_timer_fields() -> None: + context = make_execution_context() + + context.start_timer() + assert context.start_wall is not None + assert context.start_time is not None + + context.stop_timer() + assert context.end_wall is not None + assert context.end_time is not None + assert context.duration is not None + assert context.duration >= 0 + + +def test_execution_context_exception_setter_records_traceback_and_status() -> None: + context = make_execution_context(result="ignored after failure") + + context.exception = RuntimeError("boom") + + assert isinstance(context.exception, RuntimeError) + assert context.success is False + assert context.status == "ERROR" + assert context.traceback is not None + assert "RuntimeError: boom" in context.traceback + + +def test_execution_context_as_dict_includes_result_exception_traceback_duration_and_extra() -> ( + None +): + context = make_execution_context( + result={"artifact": "dist/app.whl"}, + start_time=2.0, + end_time=5.25, + extra={"attempt": 2}, + ) + context.exception = ValueError("invalid build") + + summary = context.as_dict() + + assert summary["name"] == "Build" + assert summary["result"] == {"artifact": "dist/app.whl"} + assert summary["exception"] == "ValueError('invalid build')" + assert "ValueError: invalid build" in summary["traceback"] + assert summary["duration"] == pytest.approx(3.25) + assert summary["extra"] == {"attempt": 2} + + +def test_execution_context_signature_formats_args_and_kwargs() -> None: + context = make_execution_context(args=("src", 3), kwargs={"verbose": True}) + + assert context.signature == "build ('src', 3, verbose=True)" + + +def test_execution_context_log_summary_prints_success_to_context_console() -> None: + recording_console = Console(record=True, width=160) + context = make_execution_context( + result="ok", + start_time=1.0, + end_time=2.5, + start_wall=datetime(2026, 6, 7, 11, 0, 0), + end_wall=datetime(2026, 6, 7, 11, 0, 2), + console=recording_console, + ) + + context.log_summary() + + output = recording_console.export_text() + assert "[SUMMARY] Build" in output + assert "Start: 11:00:00" in output + assert "End: 11:00:02" in output + assert "Duration: 1.500s" in output + assert "Result: ok" in output + + +def test_execution_context_log_summary_uses_logger_and_includes_exception() -> None: + messages: list[str] = [] + context = make_execution_context( + result="unused", + start_time=10.0, + end_time=11.0, + ) + context.exception = OSError("disk full") + + context.log_summary(logger=messages.append) + + assert len(messages) == 1 + assert "[SUMMARY] Build" in messages[0] + assert "Duration: 1.000s" in messages[0] + assert "Exception: OSError('disk full')" in messages[0] + + +def test_execution_context_to_log_line_renders_success_and_error_states() -> None: + success = make_execution_context(result="ok", start_time=1.0, end_time=1.5) + failure = make_execution_context(result=None, start_time=2.0, end_time=3.0) + failure.exception = LookupError("missing") + + assert success.to_log_line() == ( + "[Build] status=OK duration=0.500s result='ok' exception=None" + ) + assert failure.to_log_line() == ( + "[Build] status=ERROR duration=1.000s result=None " + "exception=LookupError: missing" + ) + + +def test_execution_context_str_and_repr_render_success_with_no_duration() -> None: + context = make_execution_context(result=["ok"]) + + text = str(context) + debug = repr(context) + + assert " None: + context = make_execution_context(start_time=1.0, end_time=1.75) + context.exception = RuntimeError("failed") + + text = str(context) + debug = repr(context) + + assert " None: + shared = make_shared_context() + error = RuntimeError("step failed") + + shared.add_result("first") + shared.add_error(1, error) + shared.set("artifact", "dist/app.whl") + + assert shared.results == ["first"] + assert shared.errors == [(1, error)] + assert shared.get("artifact") == "dist/app.whl" + assert shared.get("missing", "default") == "default" + assert shared.last_result() == "first" + + +def test_shared_context_last_result_returns_none_when_sequential_context_has_no_results() -> ( + None +): + shared = make_shared_context() + + assert shared.last_result() is None + + +def test_shared_context_set_shared_result_does_not_append_for_sequential_context() -> ( + None +): + shared = make_shared_context(is_concurrent=False) + + shared.set_shared_result("shared-value") + + assert shared.shared_result == "shared-value" + assert shared.results == [] + assert shared.last_result() is None + + +def test_shared_context_set_shared_result_appends_and_reads_from_concurrent_context() -> ( + None +): + shared = make_shared_context(is_concurrent=True) + + shared.set_shared_result("group-value") + + assert shared.shared_result == "group-value" + assert shared.results == ["group-value"] + assert shared.last_result() == "group-value" + + +def test_shared_context_str_marks_sequential_and_concurrent_modes() -> None: + sequential = make_shared_context(results=["a"]) + concurrent = make_shared_context(is_concurrent=True, results=["b"]) + + assert " None: + root = InvocationContext(program="falyx", mode=FalyxMode.MENU) + one = root.with_path_segment("admin", style="cyan") + two = one.with_path_segment("deploy", style="green") + trimmed = two.without_last_path_segment() + + assert root.typed_path == [] + assert root.segments == [] + assert one.typed_path == ["admin"] + assert one.segments[0].text == "admin" + assert str(one.segments[0].style) == "cyan" + assert two.typed_path == ["admin", "deploy"] + assert trimmed.typed_path == ["admin"] + assert trimmed.segments[0].text == "admin" + assert root.without_last_path_segment() is root + + +def test_invocation_context_plain_path_omits_program_in_menu_mode() -> None: + context = ( + InvocationContext(program="falyx", mode=FalyxMode.MENU) + .with_path_segment("admin") + .with_path_segment("deploy") + ) + + assert context.is_cli_mode is False + assert context.plain_path == "admin deploy" + + +def test_invocation_context_plain_path_includes_program_in_cli_mode() -> None: + context = ( + InvocationContext(program="falyx", mode=FalyxMode.COMMAND) + .with_path_segment("admin") + .with_path_segment("deploy") + ) + + assert context.is_cli_mode is True + assert context.plain_path == "falyx admin deploy" + + +def test_invocation_context_plain_path_handles_cli_context_without_program() -> None: + context = InvocationContext(mode=FalyxMode.COMMAND).with_path_segment("deploy") + + assert context.plain_path == "deploy" + + +def test_invocation_context_markup_path_styles_program_and_segments_and_escapes_text() -> ( + None +): + context = ( + InvocationContext( + program="falyx[dev]", + program_style="bold blue", + mode=FalyxMode.COMMAND, + ) + .with_path_segment("admin[ops]", style="cyan") + .with_path_segment("deploy", style="green") + ) + + assert context.markup_path == ( + "[bold blue]falyx\\[dev][/bold blue] " + "[cyan]admin\\[ops][/cyan] " + "[green]deploy[/green]" + ) + + +def test_invocation_context_markup_path_handles_unstyled_program_and_segments() -> None: + context = ( + InvocationContext(program="falyx", mode=FalyxMode.COMMAND) + .with_path_segment("admin[ops]") + .with_path_segment("deploy") + ) + + assert context.markup_path == "falyx admin\\[ops] deploy" + + +def test_invocation_context_markup_path_omits_program_in_menu_mode() -> None: + context = ( + InvocationContext( + program="falyx", + program_style="bold blue", + mode=FalyxMode.MENU, + ) + .with_path_segment("admin", style="cyan") + .with_path_segment("deploy") + ) + + assert context.markup_path == "[cyan]admin[/cyan] deploy" diff --git a/tests/test_execution_registry.py b/tests/test_execution_registry.py new file mode 100644 index 0000000..e3249c3 --- /dev/null +++ b/tests/test_execution_registry.py @@ -0,0 +1,307 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Iterator + +import pytest +from rich.console import Console +from rich.table import Table + +from falyx.execution_registry import ExecutionRegistry + + +@dataclass +class DummyAction: + ignore_in_history: bool = False + + +class DummyContext: + def __init__( + self, + name: str, + *, + result: Any = None, + exception: Exception | None = None, + traceback: str = "", + signature: str | None = None, + start_time: float | None = 1_700_000_000.0, + end_time: float | None = 1_700_000_001.0, + duration: float | None = 1.25, + ignore_in_history: bool = False, + ) -> None: + self.index = -1 + self.name = name + self.result = result + self.exception = exception + self.traceback = traceback + self.signature = signature or f"{name}()" + self.start_time = start_time + self.end_time = end_time + self.duration = duration + self.action = DummyAction(ignore_in_history=ignore_in_history) + self.success = exception is None + + def to_log_line(self) -> str: + return f"log:{self.name}:{self.index}" + + +class CaptureConsole: + def __init__(self) -> None: + self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] + + def print(self, *args: Any, **kwargs: Any) -> None: + self.printed.append((args, kwargs)) + + def rendered_text(self) -> str: + output = Console(record=True, width=160) + for args, kwargs in self.printed: + output.print(*args, **kwargs) + return output.export_text() + + +@pytest.fixture(autouse=True) +def isolated_registry() -> Iterator[CaptureConsole]: + original_console = ExecutionRegistry._console + capture = CaptureConsole() + + ExecutionRegistry._console = capture # type: ignore[assignment] + ExecutionRegistry._store_by_name.clear() + ExecutionRegistry._store_by_index.clear() + ExecutionRegistry._store_all.clear() + ExecutionRegistry._index = 0 + + yield capture + + ExecutionRegistry._store_by_name.clear() + ExecutionRegistry._store_by_index.clear() + ExecutionRegistry._store_all.clear() + ExecutionRegistry._index = 0 + ExecutionRegistry._console = original_console + + +def record_context(*args: Any, **kwargs: Any) -> DummyContext: + context = DummyContext(*args, **kwargs) + ExecutionRegistry.record(context) # type: ignore[arg-type] + return context + + +def latest_printed_table(console: CaptureConsole) -> Table: + assert console.printed + table = console.printed[-1][0][0] + assert isinstance(table, Table) + return table + + +def test_record_assigns_indexes_and_populates_all_lookup_stores() -> None: + first = record_context("Build", result="ok") + second = record_context("Build", result="again") + other = record_context("Deploy", result="done") + + assert first.index == 0 + assert second.index == 1 + assert other.index == 2 + assert ExecutionRegistry.get_all() == [first, second, other] + assert ExecutionRegistry.get_by_name("Build") == [first, second] + assert ExecutionRegistry.get_by_name("missing") == [] + assert ExecutionRegistry._store_by_index == {0: first, 1: second, 2: other} + assert ExecutionRegistry.get_latest() is other + + +def test_clear_removes_all_recorded_contexts() -> None: + record_context("Build", result="ok") + + ExecutionRegistry.clear() + + assert ExecutionRegistry.get_all() == [] + assert ExecutionRegistry.get_by_name("Build") == [] + assert ExecutionRegistry._store_by_index == {} + + +def test_summary_clear_clears_registry_and_prints_confirmation( + isolated_registry: CaptureConsole, +) -> None: + record_context("Build", result="ok") + + ExecutionRegistry.summary(clear=True) + + assert ExecutionRegistry.get_all() == [] + assert "Execution history cleared" in isolated_registry.rendered_text() + + +def test_summary_last_result_skips_ignored_contexts( + isolated_registry: CaptureConsole, +) -> None: + visible = record_context("Visible", result={"answer": 42}) + record_context("Ignored", result="do not show", ignore_in_history=True) + + ExecutionRegistry.summary(last_result=True) + + assert isolated_registry.printed[0][0] == (f"{visible.signature}:",) + assert isolated_registry.printed[1][0] == (visible.result,) + + +def test_summary_last_result_prints_traceback_when_latest_visible_context_failed( + isolated_registry: CaptureConsole, +) -> None: + failed = record_context("Fail", exception=RuntimeError("boom"), traceback="TRACEBACK") + + ExecutionRegistry.summary(last_result=True) + + assert isolated_registry.printed[0][0] == (f"{failed.signature}:",) + assert isolated_registry.printed[1][0] == ("TRACEBACK",) + + +def test_summary_last_result_reports_when_all_contexts_are_ignored( + isolated_registry: CaptureConsole, +) -> None: + record_context("Ignored", result="hidden", ignore_in_history=True) + + ExecutionRegistry.summary(last_result=True) + + assert "No valid executions found" in isolated_registry.rendered_text() + + +def test_summary_result_index_prints_result_for_existing_context( + isolated_registry: CaptureConsole, +) -> None: + context = record_context("Build", result=["artifact.whl"]) + + ExecutionRegistry.summary(result_index=context.index) + + assert isolated_registry.printed[0][0] == (f"{context.signature}:",) + assert isolated_registry.printed[1][0] == (context.result,) + + +def test_summary_result_index_prints_traceback_for_failed_context( + isolated_registry: CaptureConsole, +) -> None: + context = record_context("Fail", exception=ValueError("bad"), traceback="STACK") + + ExecutionRegistry.summary(result_index=context.index) + + assert isolated_registry.printed[0][0] == (f"{context.signature}:",) + assert isolated_registry.printed[1][0] == ("STACK",) + + +def test_summary_result_index_reports_missing_index( + isolated_registry: CaptureConsole, +) -> None: + ExecutionRegistry.summary(result_index=99) + + assert "No execution found for index 99" in isolated_registry.rendered_text() + + +def test_summary_name_filter_reports_missing_action( + isolated_registry: CaptureConsole, +) -> None: + record_context("Build", result="ok") + + ExecutionRegistry.summary(name="Deploy") + + assert "No executions found for action 'Deploy'" in isolated_registry.rendered_text() + + +def test_summary_name_filter_renders_only_matching_contexts( + isolated_registry: CaptureConsole, +) -> None: + record_context("Build", result="ok") + record_context("Deploy", result="done") + record_context("Build", result="again") + + ExecutionRegistry.summary(name="Build") + + table = latest_printed_table(isolated_registry) + assert table.title == "📊 Execution History for 'Build'" + assert len(table.rows) == 2 + rendered = isolated_registry.rendered_text() + assert "Build" in rendered + assert "Deploy" not in rendered + + +def test_summary_index_filter_renders_existing_context( + isolated_registry: CaptureConsole, + capsys: pytest.CaptureFixture[str], +) -> None: + first = record_context("Build", result="ok") + second = record_context("Deploy", result="done") + + ExecutionRegistry.summary(index=second.index) + + table = latest_printed_table(isolated_registry) + assert table.title == f"📊 Execution History for Index {second.index}" + assert len(table.rows) == 1 + rendered = isolated_registry.rendered_text() + assert "Deploy" in rendered + assert "Build" not in rendered + # The implementation currently prints the filtered context list directly. + assert str([second]) in capsys.readouterr().out + assert first.index == 0 + + +def test_summary_index_filter_reports_missing_index( + isolated_registry: CaptureConsole, +) -> None: + ExecutionRegistry.summary(index=12) + + assert "No execution found for index 12" in isolated_registry.rendered_text() + + +def test_summary_status_success_filters_out_errors_and_truncates_long_results( + isolated_registry: CaptureConsole, +) -> None: + long_result = "x" * 80 + record_context("Success", result=long_result) + record_context("Failure", exception=RuntimeError("boom")) + + ExecutionRegistry.summary(status="success") + + table = latest_printed_table(isolated_registry) + assert len(table.rows) == 1 + rendered = isolated_registry.rendered_text() + assert "Success" in rendered + assert "Failure" not in rendered + assert "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..." in rendered + + +def test_summary_status_error_filters_out_successes( + isolated_registry: CaptureConsole, +) -> None: + record_context("Success", result="ok") + record_context("Failure", exception=RuntimeError("boom")) + + ExecutionRegistry.summary(status="error") + + table = latest_printed_table(isolated_registry) + assert len(table.rows) == 1 + rendered = isolated_registry.rendered_text() + assert "Failure" in rendered + assert "RuntimeError" in rendered + assert "Success" not in rendered + + +def test_summary_uses_na_for_missing_timestamps_and_duration( + isolated_registry: CaptureConsole, +) -> None: + record_context("Pending", result=None, start_time=None, end_time=None, duration=None) + + ExecutionRegistry.summary() + + rendered = isolated_registry.rendered_text() + assert "Pending" in rendered + assert "n/a" in rendered + + +def test_summary_defaults_to_all_contexts( + isolated_registry: CaptureConsole, +) -> None: + record_context("One", result="ok") + record_context("Two", exception=RuntimeError("boom")) + + ExecutionRegistry.summary() + + table = latest_printed_table(isolated_registry) + assert table.title == "📊 Execution History" + assert len(table.rows) == 2 + rendered = isolated_registry.rendered_text() + assert "One" in rendered + assert "Two" in rendered diff --git a/tests/test_falyx/test_builtin_root_options.py b/tests/test_falyx/test_builtin_root_options.py new file mode 100644 index 0000000..c330f42 --- /dev/null +++ b/tests/test_falyx/test_builtin_root_options.py @@ -0,0 +1,55 @@ +import logging + +from falyx import Falyx +from falyx.action import Action +from falyx.debug import log_after, log_before, log_error, log_success +from falyx.hook_manager import HookType + + +def test_apply_root_options_sets_falyx_logger_level_from_root_verbose(): + flx = Falyx() + + falyx_logger = logging.getLogger("falyx") + original_level = falyx_logger.level + try: + flx.options_manager.set("verbose", True, "root") + flx._apply_root_options() + assert falyx_logger.level == logging.DEBUG + + flx.options_manager.set("verbose", False, "root") + flx._apply_root_options() + assert falyx_logger.level == logging.WARNING + finally: + falyx_logger.setLevel(original_level) + + +def test_apply_root_options_registers_debug_hooks_across_command_and_action_graph(): + action = Action("deploy-action", lambda: "ok") + flx = Falyx() + command = flx.add_command( + key="D", + description="Deploy", + action=action, + ) + + assert flx.hooks._hooks[HookType.BEFORE] == [] + assert command.hooks._hooks[HookType.BEFORE] == [] + assert action.hooks._hooks[HookType.BEFORE] == [] + + flx.options_manager.set("debug_hooks", True, "root") + flx._apply_root_options() + + assert flx.hooks._hooks[HookType.BEFORE] == [log_before] + assert flx.hooks._hooks[HookType.ON_SUCCESS] == [log_success] + assert flx.hooks._hooks[HookType.ON_ERROR] == [log_error] + assert flx.hooks._hooks[HookType.AFTER] == [log_after] + + assert command.hooks._hooks[HookType.BEFORE] == [log_before] + assert command.hooks._hooks[HookType.ON_SUCCESS] == [log_success] + assert command.hooks._hooks[HookType.ON_ERROR] == [log_error] + assert command.hooks._hooks[HookType.AFTER] == [log_after] + + assert action.hooks._hooks[HookType.BEFORE] == [log_before] + assert action.hooks._hooks[HookType.ON_SUCCESS] == [log_success] + assert action.hooks._hooks[HookType.ON_ERROR] == [log_error] + assert action.hooks._hooks[HookType.AFTER] == [log_after] diff --git a/tests/test_falyx/test_command_clone_contract.py b/tests/test_falyx/test_command_clone_contract.py new file mode 100644 index 0000000..2414bda --- /dev/null +++ b/tests/test_falyx/test_command_clone_contract.py @@ -0,0 +1,138 @@ +import pytest + +from falyx import Falyx +from falyx.action import Action, ChainedAction +from falyx.command import Command +from falyx.options_manager import OptionsManager +from falyx.parser import CommandArgumentParser + + +def test_add_command_from_command_returns_bound_clone(): + source = Falyx(program="source") + target = Falyx(program="target") + + original = source.add_command( + "D", + "Deploy", + action=lambda: "ok", + aliases=["deploy"], + help_text="Deploy something.", + ) + + bound = target.add_command_from_command(original) + + assert bound is target.commands["D"] + assert bound is not original + assert bound.key == original.key + assert bound.description == original.description + assert bound.aliases == original.aliases + assert bound.program == target.program + + +def test_add_command_from_command_does_not_reuse_original_options_manager(): + source = Falyx(program="source") + target = Falyx(program="target") + + original = source.add_command("D", "Deploy", action=lambda: "ok") + bound = target.add_command_from_command(original) + + assert original.options_manager is source.options_manager + assert bound.options_manager is target.options_manager + assert bound.options_manager is not original.options_manager + + +def test_add_command_from_command_returns_isolated_clone(): + flx1 = Falyx(program="one") + flx2 = Falyx(program="two") + + original = flx1.add_command("D", "Deploy", action=Action("deploy", lambda: "ok")) + bound = flx2.add_command_from_command(original) + + assert bound is not original + assert bound.options_manager is flx2.options_manager + assert original.options_manager is flx1.options_manager + + if bound.arg_parser and original.arg_parser: + assert bound.arg_parser is not original.arg_parser + assert bound.arg_parser.options_manager is flx2.options_manager + assert original.arg_parser.options_manager is flx1.options_manager + + assert bound.action is not original.action + + +def test_clone_with_overrides_clones_arg_parser_and_base_action_graph(): + original_options = OptionsManager() + cloned_options = OptionsManager() + + parser = CommandArgumentParser( + command_key="D", + command_description="Deploy", + options_manager=original_options, + ) + parser.add_argument("--region", default="us-east") + + action = ChainedAction( + name="deploy-flow", + actions=[ + Action("step-one", lambda: "one"), + Action("step-two", lambda: "two"), + ], + ) + + command = Command.build( + key="D", + description="Deploy", + action=action, + arg_parser=parser, + options_manager=original_options, + program="source", + ) + + cloned = command.clone_with_overrides( + options_manager=cloned_options, + program="target", + ) + + assert cloned is not command + assert cloned.program == "target" + assert cloned.options_manager is cloned_options + assert command.options_manager is original_options + + assert cloned.arg_parser is not command.arg_parser + assert cloned.arg_parser.options_manager is cloned_options + assert command.arg_parser.options_manager is original_options + + assert cloned.action is not command.action + assert isinstance(cloned.action, ChainedAction) + assert isinstance(command.action, ChainedAction) + + assert cloned.action.actions is not command.action.actions + assert len(cloned.action.actions) == len(command.action.actions) + + for cloned_child, original_child in zip( + cloned.action.actions, + command.action.actions, + strict=True, + ): + assert cloned_child is not original_child + assert cloned_child.name == original_child.name + + cloned.arg_parser.add_argument("--profile", default="dev") + assert command.arg_parser.get_argument("profile") is None + + +def test_clone_with_overrides_preserves_boolean_contract_flags(): + command = Command.build( + "H", + "Hidden-ish helper", + lambda: None, + auto_args=False, + simple_help_signature=True, + ignore_in_history=True, + ) + + cloned = command.clone_with_overrides() + + assert cloned.auto_args is False + assert cloned.simple_help_signature is True + assert cloned.ignore_in_history is True diff --git a/tests/test_falyx/test_command_prompt_contract.py b/tests/test_falyx/test_command_prompt_contract.py new file mode 100644 index 0000000..699b964 --- /dev/null +++ b/tests/test_falyx/test_command_prompt_contract.py @@ -0,0 +1,197 @@ +from unittest.mock import AsyncMock + +import pytest + +from falyx.action import Action +from falyx.command import Command +from falyx.options_manager import OptionsManager +from falyx.prompt_utils import should_prompt_user +from falyx.signals import CancelSignal + + +def _make_options() -> OptionsManager: + options = OptionsManager() + options.from_mapping({}, "root") + options.from_mapping({}, "execution") + return options + + +@pytest.mark.asyncio +async def test_command_handle_prompt_respects_action_local_never_prompt(monkeypatch): + options = _make_options() + + command = Command.build( + key="D", + description="Deploy", + action=Action("deploy-action", lambda: "ok", never_prompt=True), + confirm=True, + preview_before_confirm=True, + options_manager=options, + ) + + calls = { + "preview": 0, + "confirm": 0, + "should_prompt": 0, + "action_never_prompt": None, + } + + async def fake_preview(self): + calls["preview"] += 1 + + async def fake_confirm(*args, **kwargs): + calls["confirm"] += 1 + return True + + def fake_should_prompt_user(*, confirm, options, action_never_prompt=None, **kwargs): + calls["should_prompt"] += 1 + calls["action_never_prompt"] = action_never_prompt + return False + + monkeypatch.setattr(Command, "preview", fake_preview) + monkeypatch.setattr("falyx.command.confirm_async", fake_confirm) + monkeypatch.setattr("falyx.command.should_prompt_user", fake_should_prompt_user) + + await command._handle_prompt_user() + + assert calls["should_prompt"] == 1 + assert calls["action_never_prompt"] is True + assert calls["preview"] == 0 + assert calls["confirm"] == 0 + + +def test_should_prompt_user_precedence_execution_over_root(): + options = _make_options() + options.set("force_confirm", True, "root") + options.set("skip_confirm", True, "execution") + + assert should_prompt_user(confirm=False, options=options) is False + + options = _make_options() + options.set("never_prompt", False, "root") + options.set("force_confirm", True, "execution") + + assert should_prompt_user(confirm=False, options=options) is True + + +@pytest.mark.asyncio +async def test_command_local_never_prompt_overrides_root_prompt_behavior(monkeypatch): + options = _make_options() + options.set("force_confirm", True, "root") + + command = Command.build( + key="D", + description="Deploy", + action=Action("deploy-action", lambda: "ok", never_prompt=True), + confirm=False, + preview_before_confirm=True, + options_manager=options, + ) + + calls = { + "preview": 0, + "confirm": 0, + } + + async def fake_preview(self): + calls["preview"] += 1 + + async def fake_confirm(*args, **kwargs): + calls["confirm"] += 1 + return True + + monkeypatch.setattr(Command, "preview", fake_preview) + monkeypatch.setattr("falyx.command.confirm_async", fake_confirm) + + await command._handle_prompt_user() + + assert calls["preview"] == 0 + assert calls["confirm"] == 0 + + +@pytest.mark.asyncio +async def test_command_call_invokes_handle_prompt_user(monkeypatch): + options = OptionsManager() + options.from_mapping({}, "root") + options.from_mapping({}, "execution") + + command = Command.build( + key="D", + description="Deploy", + action=Action("deploy-action", lambda: "ok"), + confirm=True, + options_manager=options, + ) + + mocked_handle_prompt = AsyncMock() + monkeypatch.setattr(command, "_handle_prompt_user", mocked_handle_prompt) + + result = await command() + + mocked_handle_prompt.assert_awaited_once() + assert result == "ok" + + +@pytest.mark.asyncio +async def test_command_call_invokes_handle_prompt_user_before_action(monkeypatch) -> None: + trace: list[str] = [] + + async def run_action(): + trace.append("action") + return "ok" + + options = OptionsManager() + options.from_mapping({}, "root") + options.from_mapping({}, "execution") + + command = Command.build( + key="D", + description="Deploy", + action=Action("deploy-action", run_action), + confirm=True, + options_manager=options, + ) + + async def fake_handle_prompt_user(): + trace.append("prompt") + + monkeypatch.setattr(command, "_handle_prompt_user", fake_handle_prompt_user) + + result = await command() + + assert result == "ok" + assert trace == ["prompt", "action"] + + +@pytest.mark.asyncio +async def test_command_call_cancels_before_action_when_handle_prompt_user_raises( + monkeypatch, +): + trace: list[str] = [] + + async def run_action(): + trace.append("action") + return "ok" + + options = OptionsManager() + options.from_mapping({}, "root") + options.from_mapping({}, "execution") + + command = Command.build( + key="D", + description="Deploy", + action=Action("deploy-action", run_action), + confirm=True, + options_manager=options, + ) + + async def fake_handle_prompt_user(): + trace.append("prompt") + raise CancelSignal("cancelled during confirmation") + + monkeypatch.setattr(command, "_handle_prompt_user", fake_handle_prompt_user) + + with pytest.raises(CancelSignal, match="cancelled during confirmation"): + await command() + + assert trace == ["prompt"] diff --git a/tests/test_falyx/test_completion_contract.py b/tests/test_falyx/test_completion_contract.py new file mode 100644 index 0000000..b35a0a5 --- /dev/null +++ b/tests/test_falyx/test_completion_contract.py @@ -0,0 +1,121 @@ +from prompt_toolkit.document import Document + +from falyx import Falyx +from falyx.completer import FalyxCompleter +from falyx.parser import CommandArgumentParser + + +def completion_texts(completions) -> list[str]: + return [c.text for c in completions] + + +def make_completion_app() -> tuple[Falyx, FalyxCompleter]: + flx = Falyx(program="falyx") + + flx.add_option( + "--profile", + suggestions=["dev", "prod", "staging"], + help="Runtime profile", + ) + + flx.add_option( + "--region", + choices=["us-east", "us-west"], + help="Deployment region", + ) + + parser = CommandArgumentParser() + parser.add_argument("--name") + parser.add_argument("--env", choices=["dev", "prod"]) + + flx.add_command( + key="D", + description="Deploy", + action=lambda name, env: f"deploy {name} to {env}", + aliases=["deploy"], + arg_parser=parser, + ) + + return flx, FalyxCompleter(flx) + + +def test_completion_suggests_namespace_flags(): + _, completer = make_completion_app() + + completions = list( + completer.get_completions(Document(text="--pr", cursor_position=4), None) + ) + + texts = completion_texts(completions) + assert "--profile" in texts + + +def test_completion_suggests_namespace_option_values(): + _, completer = make_completion_app() + + completions = list( + completer.get_completions( + Document(text="--profile pr", cursor_position=len("--profile pr")), + None, + ) + ) + + texts = completion_texts(completions) + assert "prod" in texts + assert "dev" not in texts + + +def test_completion_after_committed_namespace_option_returns_namespace_entries(): + _, completer = make_completion_app() + + completions = list( + completer.get_completions( + Document(text="--profile prod de", cursor_position=len("--profile prod de")), + None, + ) + ) + + texts = completion_texts(completions) + assert "deploy" in texts + + +def test_completion_preview_mode_prefixes_namespace_entry_suggestions(): + _, completer = make_completion_app() + + completions = list( + completer.get_completions(Document(text="?de", cursor_position=3), None) + ) + + texts = completion_texts(completions) + assert "?deploy" in texts + + +def test_resolve_completion_route_unresolved_entry_with_trailing_input_stops_namespace_entry_mode(): + flx, _ = make_completion_app() + + route = flx.resolve_completion_route( + ["wat"], + stub="--na", + cursor_at_end_of_token=False, + invocation_context=flx.get_current_invocation_context(), + is_preview=False, + ) + + assert route.command is None + assert route.expecting_entry is False + assert route.remaining_argv == ["wat", "--na"] + assert route.stub == "" + + +def test_completion_delegates_to_command_parser_after_leaf_command_is_resolved(): + _, completer = make_completion_app() + + completions = list( + completer.get_completions( + Document(text="D --na", cursor_position=len("D --na")), + None, + ) + ) + + texts = completion_texts(completions) + assert "--name" in texts diff --git a/tests/test_falyx/test_dispatch_contract.py b/tests/test_falyx/test_dispatch_contract.py new file mode 100644 index 0000000..ae524f5 --- /dev/null +++ b/tests/test_falyx/test_dispatch_contract.py @@ -0,0 +1,120 @@ +import pytest + +from falyx import Falyx +from falyx.routing import RouteKind, RouteResult + + +@pytest.mark.asyncio +async def test_dispatch_seeds_namespace_defaults_into_default_namespace( + monkeypatch, +): + flx = Falyx(program="falyx") + command = flx.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"]) + + route = RouteResult( + kind=RouteKind.COMMAND, + namespace=flx, + context=flx.get_current_invocation_context(), + command=command, + namespace_defaults={"region": "us-east"}, + namespace_overrides={}, + ) + + seen = {} + + async def fake_execute(*, command, args, kwargs, execution_args, **_): + seen["region"] = flx.options_manager.get("region", None, "default") + return "ok" + + monkeypatch.setattr(flx._executor, "execute", fake_execute) + + result = await flx._dispatch_route( + route=route, + args=(), + kwargs={}, + execution_args={}, + ) + + assert result == "ok" + assert seen["region"] == "us-east" + + assert flx.options_manager.get("region", None, "default") == "us-east" + + +@pytest.mark.asyncio +async def test_dispatch_applies_namespace_overrides_temporarily_in_default_namespace( + monkeypatch, +): + flx = Falyx(program="falyx") + command = flx.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"]) + + flx.options_manager.set("region", "us-east", "default") + + route = RouteResult( + kind=RouteKind.COMMAND, + namespace=flx, + context=flx.get_current_invocation_context(), + command=command, + namespace_defaults={}, + namespace_overrides={"region": "us-west"}, + ) + + seen = {} + + async def fake_execute(*, command, args, kwargs, execution_args, **_): + seen["region"] = flx.options_manager.get("region", None, "default") + return "ok" + + monkeypatch.setattr(flx._executor, "execute", fake_execute) + + result = await flx._dispatch_route( + route=route, + args=(), + kwargs={}, + execution_args={}, + raise_on_error=False, + wrap_errors=True, + ) + + assert result == "ok" + assert seen["region"] == "us-west" + + assert flx.options_manager.get("region", None, "default") == "us-east" + + +@pytest.mark.asyncio +async def test_namespace_overrides_do_not_leak_after_command_execution(monkeypatch): + flx = Falyx(program="falyx") + command = flx.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"]) + + flx.options_manager.set("profile", "dev", "default") + + route = RouteResult( + kind=RouteKind.COMMAND, + namespace=flx, + context=flx.get_current_invocation_context(), + command=command, + namespace_defaults={"region": "us-east"}, + namespace_overrides={"profile": "prod"}, + ) + + async def fake_execute(*, command, args, kwargs, execution_args, **_): + assert flx.options_manager.get("region", None, "default") == "us-east" + assert flx.options_manager.get("profile", None, "default") == "prod" + return "ok" + + monkeypatch.setattr(flx._executor, "execute", fake_execute) + + result = await flx._dispatch_route( + route=route, + args=(), + kwargs={}, + execution_args={}, + raise_on_error=False, + wrap_errors=True, + ) + + assert result == "ok" + + assert flx.options_manager.get("region", None, "default") == "us-east" + assert flx.options_manager.get("profile", None, "default") == "dev" diff --git a/tests/test_falyx/test_exceptions.py b/tests/test_falyx/test_exceptions.py new file mode 100644 index 0000000..e15226f --- /dev/null +++ b/tests/test_falyx/test_exceptions.py @@ -0,0 +1,68 @@ +import pytest + +from falyx.console import print_error +from falyx.exceptions import CommandArgumentError, MissingValueError +from falyx.parser import CommandArgumentParser + + +async def test_missing_value_error_has_user_facing_message(): + parser = CommandArgumentParser() + parser.add_argument("--pair", type=int, nargs=2) + + with pytest.raises(MissingValueError) as exc: + await parser.parse_args(["--pair", "1"]) + + assert "pair" in str(exc.value) + assert "expected" in str(exc.value).lower() + + +@pytest.mark.asyncio +async def test_missing_value_error_for_fixed_nargs_has_message_and_hint(): + parser = CommandArgumentParser() + parser.add_argument("--pair", type=int, nargs=2) + + with pytest.raises(MissingValueError) as exc: + await parser.parse_args(["--pair", "1"]) + + error = exc.value + + assert str(error) == "missing values for '--pair': expected 2, got 1" + assert error.hint == "provide 2 values for '--pair'." + assert error.show_short_usage is True + assert error.dest == "pair" + + +@pytest.mark.asyncio +async def test_missing_value_error_for_plus_nargs_has_message_and_hint(): + parser = CommandArgumentParser() + parser.add_argument("--items", nargs="+") + + with pytest.raises(MissingValueError) as exc: + await parser.parse_args(["--items"]) + + error = exc.value + + assert str(error) == "missing value for '--items'" + assert error.hint == "provide one or more values for '--items'." + + +def test_print_error_uses_exception_hint(monkeypatch) -> None: + printed: list[str] = [] + + class FakeConsole: + def print(self, value): + printed.append(value) + + monkeypatch.setattr("falyx.console.error_console", FakeConsole()) + + error = CommandArgumentError( + "invalid command argument", + hint="use --help to see available options", + ) + + print_error(error) + + assert any("error:" in line for line in printed) + assert any("invalid command argument" in line for line in printed) + assert any("hint:" in line for line in printed) + assert any("use --help to see available options" in line for line in printed) diff --git a/tests/test_falyx/test_execute_command.py b/tests/test_falyx/test_execute_command.py index 692a5fb..5e42dde 100644 --- a/tests/test_falyx/test_execute_command.py +++ b/tests/test_falyx/test_execute_command.py @@ -2,6 +2,8 @@ import pytest from falyx import Falyx from falyx.action import Action +from falyx.command_runner import CommandRunner +from falyx.parser import CommandArgumentParser @pytest.mark.asyncio @@ -9,18 +11,32 @@ async def test_execute_command(): """Test if Falyx can run in run key mode.""" falyx = Falyx("Run Key Test") - # Add a simple command falyx.add_command( key="T", description="Test Command", action=lambda: "Hello, World!", ) - # Run the CLI result = await falyx.execute_command("T") assert result == "Hello, World!" +@pytest.mark.asyncio +async def test_execute_command_accepts_alias(): + """Falyx.execute_command should resolve command aliases.""" + falyx = Falyx("Alias Test") + + falyx.add_command( + key="T", + description="Test Command", + action=lambda: "Hello, Alias!", + aliases=["test"], + ) + + result = await falyx.execute_command("test") + assert result == "Hello, Alias!" + + @pytest.mark.asyncio async def test_execute_command_recover(): """Test if Falyx can recover from a failure in run key mode.""" @@ -34,7 +50,6 @@ async def test_execute_command_recover(): raise RuntimeError("Random failure!") return "ok" - # Add a command that raises an exception falyx.add_command( key="E", description="Error Command", @@ -44,3 +59,66 @@ async def test_execute_command_recover(): result = await falyx.execute_command("E") assert result == "ok" + + +@pytest.mark.asyncio +async def test_execute_command_with_argument_parsing(): + """Falyx.execute_command should parse command-local arguments before execution.""" + falyx = Falyx("Argument Parsing Test") + + falyx.add_command( + key="G", + description="Greet", + action=lambda name: f"hello {name}", + ) + + result = await falyx.execute_command("G Roland") + assert result == "hello Roland" + + +@pytest.mark.asyncio +async def test_command_runner_and_falyx_execute_same_command_with_same_result(): + """CommandRunner and Falyx should produce the same result for equivalent input.""" + falyx = Falyx("Parity Test") + + command = falyx.add_command( + key="G", + description="Greet", + action=lambda name: f"hello {name}", + aliases=["greet"], + ) + + runner = CommandRunner.from_command(command) + + falyx_result = await falyx.execute_command("G Roland") + runner_result = await runner.run(["Roland"]) + + assert falyx_result == "hello Roland" + assert runner_result == "hello Roland" + assert falyx_result == runner_result + + +@pytest.mark.asyncio +async def test_command_runner_from_command_clones_and_preserves_parity(): + """Runner parity should hold even though from_command binds a clone.""" + falyx = Falyx("Clone Parity Test") + + parser = CommandArgumentParser() + parser.add_argument("x", type=int) + parser.add_argument("y", type=int) + + command = falyx.add_command( + key="A", + description="Add", + action=lambda x, y: x + y, + arg_parser=parser, + ) + + runner = CommandRunner.from_command(command) + + result_from_falyx = await falyx.execute_command("A 2 3") + result_from_runner = await runner.run(["2", "3"]) + + assert result_from_falyx == 5 + assert result_from_runner == 5 + assert runner.command is not command diff --git a/tests/test_falyx/test_extra.py b/tests/test_falyx/test_extra.py new file mode 100644 index 0000000..949b786 --- /dev/null +++ b/tests/test_falyx/test_extra.py @@ -0,0 +1,856 @@ +from __future__ import annotations + +import asyncio +from contextlib import nullcontext +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from prompt_toolkit.validation import ValidationError +from rich.table import Table +from rich.text import Text + +import falyx.falyx as falyx_module +from falyx import Falyx +from falyx.command import Command +from falyx.exceptions import ( + CommandAlreadyExistsError, + CommandArgumentError, + EntryNotFoundError, + FalyxError, + InvalidActionError, + InvalidHookError, + NotAFalyxError, + UsageError, +) +from falyx.hook_manager import HookType +from falyx.mode import FalyxMode +from falyx.namespace import FalyxNamespace +from falyx.parser.parser_types import FalyxTLDRExample +from falyx.routing import RouteKind, RouteResult +from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal + + +class RecordingConsole: + def __init__(self) -> None: + self.calls: list[tuple[tuple, dict]] = [] + + def print(self, *args, **kwargs) -> None: + self.calls.append((args, kwargs)) + + @property + def rendered(self) -> str: + parts: list[str] = [] + for args, _ in self.calls: + if args: + value = args[0] + if isinstance(value, Text): + parts.append(value.plain) + else: + parts.append(str(value)) + return "\n".join(parts) + + +def make_falyx(**overrides) -> Falyx: + defaults = { + "program": "fx", + "description": "Test CLI", + "enable_help_tips": False, + } + defaults.update(overrides) + return Falyx(**defaults) + + +def add_deploy( + flx: Falyx, *, key: str = "D", aliases: list[str] | None = None +) -> Command: + return flx.add_command( + key, + description="Deploy", + action=lambda: "deployed", + aliases=aliases if aliases is not None else ["deploy"], + help_text="Deploy things.", + ) + + +def route_for(flx: Falyx, kind: RouteKind, **overrides) -> RouteResult: + values = { + "kind": kind, + "namespace": flx, + "context": flx.get_current_invocation_context(), + } + values.update(overrides) + return RouteResult(**values) + + +def test_init_with_prompt_history_sanitizes_program_name(tmp_path) -> None: + flx = Falyx( + program="my app.cli", + prompt_history_base_dir=tmp_path, + enable_prompt_history=True, + ) + + assert flx.history_path == tmp_path / ".my_app_history" + assert flx.history is not None + + +def test_str_and_repr_include_identity_fields() -> None: + flx = Falyx(program="fx", title="Deployments", description="Deploy CLI") + + expected = "Falyx(program='fx', title='Deployments', description='Deploy CLI')" + assert str(flx) == expected + assert repr(flx) == expected + + +def test_add_tldr_examples_delegates_to_root_parser() -> None: + flx = make_falyx() + add_deploy(flx) + + flx.add_tldr_examples([("deploy", "--region us-east", "Deploy east")]) + + assert flx.parser.tldr_option is not None + assert flx.parser._tldr_examples[-1].entry_key == "deploy" + + +def test_rejects_invalid_options_manager() -> None: + with pytest.raises(NotAFalyxError, match="options_manager"): + Falyx(options_manager=object()) + + +def test_entry_map_rejects_identifier_collision_with_distinct_entries() -> None: + flx = make_falyx() + add_deploy(flx) + flx.namespaces["N"] = FalyxNamespace( + key="N", + description="Nested", + namespace=make_falyx(), + aliases=["Deploy"], + ) + + with pytest.raises(CommandAlreadyExistsError, match="identifier 'DEPLOY'"): + _ = flx._entry_map + + +def test_get_tip_adds_menu_specific_tips(monkeypatch) -> None: + flx = make_falyx() + flx.options_manager.set("mode", FalyxMode.MENU) + seen: dict[str, list[str]] = {} + + def choose_last(tips: list[str]) -> str: + seen["tips"] = tips + return tips[-1] + + monkeypatch.setattr(falyx_module, "choice", choose_last) + + assert flx.get_tip() == "Use '[X]' in menu mode to exit." + assert "'[Y]' opens the command history viewer." in seen["tips"] + + +def test_command_key_usage_in_menu_includes_history_and_exit() -> None: + flx = make_falyx() + add_deploy(flx) + flx.options_manager.set("mode", FalyxMode.MENU) + + usage = flx._get_command_keys_usage_string() + + assert "D" in usage + assert "Y" in usage + assert "X" in usage + + +def test_simple_usage_mentions_namespace_when_visible_namespace_exists() -> None: + flx = make_falyx(simple_usage=True) + flx.add_submenu("OPS", "Operations", make_falyx()) + + fragment = flx._get_usage_fragment(flx.get_current_invocation_context()) + + assert "" in fragment + + +def test_get_usage_omits_invocation_path_in_menu_mode() -> None: + flx = make_falyx(usage="custom [args]") + flx.options_manager.set("mode", FalyxMode.MENU) + + usage = flx._get_usage() + + assert usage == "[bold]usage:[/bold] [white]custom [args][/white]" + + +@pytest.mark.asyncio +async def test_render_command_tldr_prints_tip_when_examples_render(monkeypatch) -> None: + flx = make_falyx(enable_help_tips=True) + flx.console = RecordingConsole() + monkeypatch.setattr(flx, "get_tip", lambda: "remember aliases") + + command = SimpleNamespace( + description="Deploy", + render_tldr=lambda invocation_context: True, + ) + + await flx._render_command_tldr(command) + + assert "remember aliases" in flx.console.rendered + + +@pytest.mark.asyncio +async def test_render_command_tldr_prints_error_when_no_examples(monkeypatch) -> None: + flx = make_falyx() + messages: list[str] = [] + monkeypatch.setattr( + falyx_module, "print_error", lambda message, **_: messages.append(str(message)) + ) + command = SimpleNamespace( + description="Deploy", + render_tldr=lambda invocation_context: False, + ) + + await flx._render_command_tldr(command) + + assert messages == ["No TLDR examples available for 'Deploy'."] + + +@pytest.mark.asyncio +async def test_render_command_help_delegates_to_tldr(monkeypatch) -> None: + flx = make_falyx() + command = SimpleNamespace(description="Deploy") + context = flx.get_current_invocation_context() + called: dict[str, object] = {} + + async def fake_tldr(rendered_command, invocation_context=None) -> None: + called["command"] = rendered_command + called["context"] = invocation_context + + monkeypatch.setattr(flx, "_render_command_tldr", fake_tldr) + + await flx._render_command_help(command, tldr=True, invocation_context=context) + + assert called == {"command": command, "context": context} + + +@pytest.mark.asyncio +async def test_render_command_help_prints_tip_when_help_renders(monkeypatch) -> None: + flx = make_falyx(enable_help_tips=True) + flx.console = RecordingConsole() + monkeypatch.setattr(flx, "get_tip", lambda: "read the usage line") + command = SimpleNamespace( + description="Deploy", + render_help=lambda invocation_context: True, + ) + + await flx._render_command_help(command) + + assert "read the usage line" in flx.console.rendered + + +@pytest.mark.asyncio +async def test_render_command_help_prints_error_when_no_help(monkeypatch) -> None: + flx = make_falyx() + messages: list[str] = [] + monkeypatch.setattr( + falyx_module, "print_error", lambda message, **_: messages.append(str(message)) + ) + command = SimpleNamespace( + description="Deploy", + render_help=lambda invocation_context: False, + ) + + await flx._render_command_help(command) + + assert messages == ["No detailed help available for 'Deploy'."] + + +@pytest.mark.asyncio +async def test_render_tag_help_prints_empty_tag_message() -> None: + flx = make_falyx() + flx.console = RecordingConsole() + + await flx._render_tag_help("missing") + + assert "Nothing to show here" in flx.console.rendered + + +@pytest.mark.asyncio +async def test_render_menu_help_includes_namespaces_and_epilog(monkeypatch) -> None: + monkeypatch.setattr( + FalyxNamespace, + "get_help_signature", + lambda self, context: (self.key, self.description, ""), + raising=False, + ) + flx = make_falyx(epilog="Menu epilog") + flx.console = RecordingConsole() + flx.add_submenu("OPS", "Operations namespace", make_falyx()) + + await flx._render_menu_help(flx.get_current_invocation_context()) + + assert "namespaces" in flx.console.rendered + assert "Menu epilog" in flx.console.rendered + + +@pytest.mark.asyncio +async def test_render_cli_help_includes_namespaces_aliases_and_epilog() -> None: + flx = make_falyx(epilog="CLI epilog") + flx.console = RecordingConsole() + flx.add_submenu("OPS", "Operations namespace", make_falyx(), aliases=["operations"]) + + await flx._render_cli_help(flx.get_current_invocation_context()) + + rendered = flx.console.rendered + assert "namespaces" in rendered + assert "OPS | operations" in rendered + assert "Operations namespace" in rendered + assert "CLI epilog" in rendered + + +@pytest.mark.asyncio +async def test_namespace_tldr_prints_empty_message_without_examples() -> None: + flx = make_falyx(title="Root Menu") + flx.console = RecordingConsole() + + await flx._render_namespace_tldr_help(flx.get_current_invocation_context()) + + assert "No TLDR examples available for 'Root Menu'" in flx.console.rendered + + +@pytest.mark.asyncio +async def test_namespace_tldr_rejects_stale_unknown_example() -> None: + flx = make_falyx() + flx.parser.tldr_option = object() + flx.parser._tldr_examples.append( + FalyxTLDRExample( + entry_key="missing", + usage="", + description="Stale example", + ) + ) + + with pytest.raises(EntryNotFoundError) as error: + await flx._render_namespace_tldr_help(flx.get_current_invocation_context()) + + assert error.value.unknown_name == "missing" + + +def test_help_target_base_context_handles_empty_and_help_command_path() -> None: + flx = make_falyx() + base_context = flx.get_current_invocation_context() + + assert flx._help_target_base_context(base_context) is base_context + + help_context = base_context.with_path_segment("H", style=flx.help_command.style) + stripped = flx._help_target_base_context(help_context) + + assert stripped.typed_path == [] + + +@pytest.mark.asyncio +async def test_render_help_dispatches_to_specific_command(monkeypatch) -> None: + flx = make_falyx() + command = add_deploy(flx) + called: dict[str, object] = {} + + async def fake_command_help(command, tldr=False, invocation_context=None) -> None: + called["command"] = command + called["tldr"] = tldr + called["path"] = list(invocation_context.typed_path) + + monkeypatch.setattr(flx, "_render_command_help", fake_command_help) + + await flx.render_help(key="deploy", tldr=True) + + assert called == {"command": command, "tldr": True, "path": ["deploy"]} + + +@pytest.mark.asyncio +async def test_render_help_dispatches_to_specific_namespace(monkeypatch) -> None: + flx = make_falyx() + submenu = make_falyx() + called: dict[str, object] = {} + flx.add_submenu("OPS", "Operations", submenu) + + async def fake_namespace_help(invocation_context=None, tldr=False) -> None: + called["tldr"] = tldr + called["path"] = list(invocation_context.typed_path) + + monkeypatch.setattr(submenu, "render_namespace_help", fake_namespace_help) + + await flx.render_help(key="OPS", tldr=True) + + assert called == {"tldr": True, "path": ["OPS"]} + + +@pytest.mark.asyncio +async def test_render_help_renders_namespace_then_raises_for_unknown_key( + monkeypatch, +) -> None: + flx = make_falyx() + rendered: list[InvocationContext] = [] + + async def fake_namespace_help(invocation_context=None, tldr=False) -> None: + rendered.append(invocation_context) + + monkeypatch.setattr(flx, "render_namespace_help", fake_namespace_help) + + with pytest.raises(EntryNotFoundError) as error: + await flx.render_help(key="depoy") + + assert rendered + assert error.value.unknown_name == "depoy" + + +@pytest.mark.asyncio +async def test_render_help_without_key_tldr_renders_help_command_tldr( + monkeypatch, +) -> None: + flx = make_falyx() + called: dict[str, object] = {} + + async def fake_command_help(command, tldr=False, invocation_context=None) -> None: + called["command"] = command + called["tldr"] = tldr + + monkeypatch.setattr(flx, "_render_command_help", fake_command_help) + + await flx.render_help(tldr=True) + + assert called == {"command": flx.help_command, "tldr": True} + + +@pytest.mark.asyncio +async def test_preview_rejects_namespaces_and_unknown_entries() -> None: + flx = make_falyx() + flx.add_submenu("OPS", "Operations", make_falyx()) + + with pytest.raises(FalyxError, match="preview mode"): + await flx._preview("OPS") + + with pytest.raises(EntryNotFoundError) as error: + await flx._preview("missing") + + assert error.value.unknown_name == "missing" + + +@pytest.mark.asyncio +async def test_render_version_prints_program_version() -> None: + flx = make_falyx(program="fx", version="9.9.9") + flx.console = RecordingConsole() + + await flx._render_version() + + assert "fx v9.9.9" in flx.console.rendered + + +def test_invalidate_prompt_session_cache_deletes_cached_property_value() -> None: + flx = make_falyx() + flx.__dict__["prompt_session"] = object() + flx._prompt_session = object() + + flx._invalidate_prompt_session_cache() + + assert "prompt_session" not in flx.__dict__ + assert flx._prompt_session is None + + +def test_bottom_bar_accepts_instance_string_callable_and_rejects_invalid() -> None: + flx = make_falyx() + existing_bottom_bar = flx.bottom_bar + + flx.bottom_bar = existing_bottom_bar + assert flx.bottom_bar is existing_bottom_bar + assert flx.bottom_bar.key_bindings is flx.key_bindings + + flx.bottom_bar = "static toolbar" + assert flx._get_bottom_bar_render() == "static toolbar" + + renderer = lambda: "dynamic toolbar" + flx.bottom_bar = renderer + assert flx._get_bottom_bar_render() is renderer + + with pytest.raises(FalyxError, match="bottom_bar"): + flx.bottom_bar = object() + + +def test_default_bottom_bar_render_is_returned_when_items_exist() -> None: + flx = make_falyx() + render = flx._get_bottom_bar_render() + + if flx.bottom_bar.has_items: + assert render is flx.bottom_bar.render + else: + assert render is None + + +def test_register_all_hooks_rejects_non_callable_hook() -> None: + flx = make_falyx() + + with pytest.raises(InvalidHookError, match="callable"): + flx.register_all_hooks(HookType.BEFORE, object()) + + +def test_validate_command_aliases_rejects_duplicate_aliases() -> None: + flx = make_falyx() + + with pytest.raises(CommandAlreadyExistsError, match="duplicate aliases"): + flx.add_command( + "D", description="Deploy", action=lambda: None, aliases=["deploy", "DEPLOY"] + ) + + +def test_validate_command_aliases_rejects_key_as_alias() -> None: + flx = make_falyx() + + with pytest.raises(CommandAlreadyExistsError, match="cannot also be an alias"): + flx.add_command("D", description="Deploy", action=lambda: None, aliases=["D"]) + + +def test_validate_command_aliases_rejects_existing_identifier_collision() -> None: + flx = make_falyx() + + with pytest.raises(CommandAlreadyExistsError, match="already exist"): + flx.add_command("H", description="Duplicate Help", action=lambda: None) + + +def test_update_exit_command_rejects_non_callable_action() -> None: + flx = make_falyx() + + with pytest.raises(InvalidActionError, match="callable"): + flx.update_exit_command(key="Q", action="quit") + + +def test_add_submenu_rejects_non_falyx_submenu() -> None: + flx = make_falyx() + + with pytest.raises(NotAFalyxError, match="submenu"): + flx.add_submenu("OPS", "Operations", object()) + + +def test_add_commands_accepts_dicts_and_command_instances() -> None: + flx = make_falyx() + reusable = Command(key="B", description="Build", action=lambda: "built") + + commands = flx.add_commands( + [ + {"key": "D", "description": "Deploy", "action": lambda: "deployed"}, + reusable, + ] + ) + + assert [command.key for command in commands] == ["D", "B"] + assert flx.commands["D"].description == "Deploy" + assert flx.commands["B"].description == "Build" + + +def test_add_commands_rejects_invalid_items() -> None: + flx = make_falyx() + + with pytest.raises(FalyxError, match="dictionary or an instance of Command"): + flx.add_commands([object()]) + + +def test_add_command_from_command_rejects_non_command() -> None: + flx = make_falyx() + + with pytest.raises(FalyxError, match="instance of Command"): + flx.add_command_from_command(object()) + + +def test_iter_visible_entries_can_include_builtins() -> None: + flx = make_falyx() + visible = flx._iter_visible_entries(include_builtins=True) + + assert any(entry.key == "H" for entry in visible) + assert any(entry.key == "PVW" for entry in visible) + assert any(entry.key == "VER" for entry in visible) + + +def test_build_placeholder_menu_returns_empty_placeholder_without_user_commands() -> None: + flx = make_falyx() + + assert flx.build_placeholder_menu() == [("", "")] + + +def test_table_uses_callable_custom_table_and_rejects_invalid_factory() -> None: + good = make_falyx(custom_table=lambda app: Table(title=app.title)) + assert isinstance(good.table, Table) + + bad = make_falyx(custom_table=lambda app: "not a table") + with pytest.raises(FalyxError, match="custom_table"): + _ = bad.table + + +def test_table_uses_prebuilt_custom_table_instance() -> None: + table = Table(title="Prebuilt") + flx = make_falyx(custom_table=table) + + assert flx.table is table + + +def test_resolve_entry_accepts_unique_prefix_matches() -> None: + flx = make_falyx() + command = add_deploy(flx, key="DEPLOY", aliases=[]) + + entry, suggestions = flx.resolve_entry("depl") + + assert entry is command + assert suggestions == [] + + +@pytest.mark.asyncio +async def test_prepare_route_converts_bad_shell_string_to_validation_error() -> None: + flx = make_falyx() + + with pytest.raises(ValidationError): + await flx.prepare_route('"unterminated', from_validate=True) + + +@pytest.mark.asyncio +async def test_prepare_route_converts_bad_shell_string_to_usage_error() -> None: + flx = make_falyx() + + with pytest.raises(UsageError, match="No closing quotation"): + await flx.prepare_route('"unterminated') + + +@pytest.mark.asyncio +async def test_prepare_route_rejects_invalid_raw_argument_type() -> None: + flx = make_falyx() + + with pytest.raises(AssertionError, match="Validator can only pass"): + await flx.prepare_route(object(), from_validate=True) + + with pytest.raises(UsageError, match="raw_arguments"): + await flx.prepare_route(object()) + + +@pytest.mark.asyncio +async def test_prepare_route_preserves_preview_route_without_resolving_command_args() -> ( + None +): + flx = make_falyx() + add_deploy(flx) + + route, args, kwargs, execution_args = await flx.prepare_route("?D") + + assert route.is_preview is True + assert route.kind is RouteKind.COMMAND + assert args == () + assert kwargs == {} + assert execution_args == {} + + +@pytest.mark.asyncio +async def test_prepare_route_wraps_route_errors_for_validation(monkeypatch) -> None: + flx = make_falyx() + + async def fake_resolve_route(*args, **kwargs): + raise FalyxError("bad route", hint="try deploy") + + monkeypatch.setattr(flx, "resolve_route", fake_resolve_route) + + with pytest.raises(ValidationError) as error: + await flx.prepare_route("D", from_validate=True) + + assert "try deploy" in str(error.value) + + +@pytest.mark.asyncio +async def test_prepare_route_wraps_command_argument_errors_for_validation( + monkeypatch, +) -> None: + flx = make_falyx() + command = add_deploy(flx) + + async def fake_resolve_route(*args, **kwargs): + return route_for(flx, RouteKind.COMMAND, command=command, leaf_argv=["--bad"]) + + async def fake_resolve_args(*args, **kwargs): + raise CommandArgumentError("bad args", hint="use --help") + + monkeypatch.setattr(flx, "resolve_route", fake_resolve_route) + monkeypatch.setattr(Command, "resolve_args", fake_resolve_args) + + with pytest.raises(ValidationError) as error: + await flx.prepare_route(["D"], from_validate=True) + + assert "use --help" in str(error.value) + + +@pytest.mark.asyncio +async def test_render_unknown_route_rejects_preview_namespace_menu() -> None: + flx = make_falyx() + route = route_for(flx, RouteKind.NAMESPACE_MENU) + + with pytest.raises(FalyxError, match="preview mode"): + await flx._render_unknown_route(route) + + +@pytest.mark.asyncio +async def test_dispatch_route_previews_command_and_unknown_preview(monkeypatch) -> None: + flx = make_falyx() + command = SimpleNamespace(key="D", preview=AsyncMock()) + command_route = route_for( + flx, + RouteKind.COMMAND, + command=command, + is_preview=True, + ) + + await flx._dispatch_route(route=command_route) + command.preview.assert_awaited_once() + + unknown_route = route_for( + flx, RouteKind.UNKNOWN, current_head="missing", is_preview=True + ) + rendered: list[RouteResult] = [] + + async def fake_unknown(route): + rendered.append(route) + + monkeypatch.setattr(flx, "_render_unknown_route", fake_unknown) + + await flx._dispatch_route(route=unknown_route) + assert rendered == [unknown_route] + + +@pytest.mark.asyncio +async def test_dispatch_route_unknown_returns_after_rendering(monkeypatch) -> None: + flx = make_falyx() + route = route_for(flx, RouteKind.UNKNOWN, current_head="missing") + rendered: list[RouteResult] = [] + + async def fake_unknown(route): + rendered.append(route) + + monkeypatch.setattr(flx, "_render_unknown_route", fake_unknown) + + assert await flx._dispatch_route(route=route) is None + assert rendered == [route] + + +@pytest.mark.asyncio +async def test_dispatch_route_rejects_command_route_without_command() -> None: + flx = make_falyx() + route = route_for(flx, RouteKind.COMMAND, command=None) + + with pytest.raises(FalyxError, match="command expected"): + await flx._dispatch_route(route=route) + + +@pytest.mark.asyncio +async def test_execute_command_requires_error_policy() -> None: + flx = make_falyx() + + with pytest.raises(FalyxError, match="requires either"): + await flx.execute_command("D", raise_on_error=False, wrap_errors=False) + + +def test_resolve_completion_route_returns_entry_completion_for_unknown_committed_token() -> ( + None +): + flx = make_falyx() + context = flx.get_current_invocation_context() + + route = flx.resolve_completion_route( + ["depoy"], + stub="", + cursor_at_end_of_token=False, + invocation_context=context, + ) + + assert route.expecting_entry is True + assert route.stub == "depoy" + assert route.command is None + + +@pytest.mark.asyncio +async def test_process_command_executes_prompt_input_and_reports_falyx_error( + monkeypatch, +) -> None: + flx = make_falyx() + errors: list[object] = [] + invalidated: list[bool] = [] + + class FakeApp: + def invalidate(self) -> None: + invalidated.append(True) + + class FakeSession: + async def prompt_async(self) -> str: + return "D" + + async def fake_execute_command(*args, **kwargs): + raise FalyxError("boom") + + monkeypatch.setattr(falyx_module, "get_app", lambda: FakeApp()) + monkeypatch.setattr(falyx_module.asyncio, "sleep", AsyncMock()) + monkeypatch.setattr(falyx_module, "patch_stdout", lambda raw=True: nullcontext()) + monkeypatch.setattr( + falyx_module, "print_error", lambda message, **_: errors.append(message) + ) + monkeypatch.setattr(flx, "execute_command", fake_execute_command) + flx.__dict__["prompt_session"] = FakeSession() + + await flx._process_command() + + assert invalidated == [True] + assert isinstance(errors[0], FalyxError) + + +@pytest.mark.asyncio +async def test_menu_handles_flow_signals_and_prints_welcome_and_exit(monkeypatch) -> None: + rendered: list[Falyx] = [] + flx = make_falyx( + welcome_message="welcome", + exit_message="goodbye", + render_menu=lambda app: rendered.append(app), + ) + flx.console = RecordingConsole() + signals: list[BaseException] = [ + HelpSignal(), + BackSignal(), + CancelSignal(), + asyncio.CancelledError(), + QuitSignal(), + ] + + async def fake_process_command() -> None: + raise signals.pop(0) + + monkeypatch.setattr(flx, "_process_command", fake_process_command) + + await flx.menu() + + assert rendered == [flx, flx, flx, flx, flx] + assert "welcome" in flx.console.rendered + assert "goodbye" in flx.console.rendered + + +@pytest.mark.asyncio +async def test_run_logs_verbose_unhandled_errors_before_exit(monkeypatch) -> None: + flx = make_falyx() + flx.options_manager.set("verbose", True, "root") + context = flx.get_current_invocation_context() + route = RouteResult(kind=RouteKind.NAMESPACE_MENU, namespace=flx, context=context) + logged: list[tuple[tuple, dict]] = [] + + async def fake_prepare_route(*args, **kwargs): + return route, (), {}, {} + + async def fake_dispatch_route(*args, **kwargs): + raise FalyxError("boom") + + monkeypatch.setattr(flx, "prepare_route", fake_prepare_route) + monkeypatch.setattr(flx, "_dispatch_route", fake_dispatch_route) + monkeypatch.setattr(falyx_module.sys, "argv", ["fx", "D"]) + monkeypatch.setattr(falyx_module, "print_error", lambda message, **_: None) + monkeypatch.setattr( + falyx_module.logger, + "error", + lambda *args, **kwargs: logged.append((args, kwargs)), + ) + + with pytest.raises(SystemExit) as error: + await flx.run() + + assert error.value.code == 1 + assert logged + assert logged[0][1]["exc_info"] is True diff --git a/tests/test_falyx/test_help.py b/tests/test_falyx/test_help.py index 58d936a..6f8f3c7 100644 --- a/tests/test_falyx/test_help.py +++ b/tests/test_falyx/test_help.py @@ -90,6 +90,6 @@ async def test_help_command_bad_argument(capsys): flx.add_command("U", "Untagged Command", untagged_command) with pytest.raises( - CommandArgumentError, match="Unexpected positional argument: nonexistent_tag" + CommandArgumentError, match="unexpected positional argument: nonexistent_tag" ): await flx.execute_command("H nonexistent_tag") diff --git a/tests/test_falyx/test_options_manager_contract.py b/tests/test_falyx/test_options_manager_contract.py new file mode 100644 index 0000000..c81b8c2 --- /dev/null +++ b/tests/test_falyx/test_options_manager_contract.py @@ -0,0 +1,219 @@ +import pytest + +from falyx import Falyx +from falyx.action import Action, ChainedAction +from falyx.command import Command +from falyx.options_manager import OptionsManager + + +def test_seed_missing_and_override_namespace_do_not_leak(): + options = OptionsManager() + options.set("verbose", True, "root") + + options.seed_missing({"verbose": False, "debug_hooks": False}, "root") + assert options.get("verbose", namespace_name="root") is True + assert options.get("debug_hooks", namespace_name="root") is False + + with options.override_namespace({"verbose": False}, "root"): + assert options.get("verbose", namespace_name="root") is False + + assert options.get("verbose", namespace_name="root") is True + + +def test_command_and_action_read_options_from_expected_namespace(): + options = OptionsManager() + options.from_mapping({"region": "us-east"}, "default") + options.from_mapping({"never_prompt": True, "verbose": True}, "root") + + action = Action("deploy-action", lambda: "ok") + command = Command.build( + key="D", + description="Deploy", + action=action, + options_manager=options, + ) + + command._inject_options_manager() + + assert command.get_option("region") == "us-east" + assert command.get_option("verbose", namespace_name="root") is True + + assert action.get_option("region") == "us-east" + assert action.get_option("verbose", namespace_name="root") is True + assert action.never_prompt is True + assert action.local_never_prompt is None + + +def test_all_objects_in_one_namespace_share_same_options_manager(): + flx = Falyx(program="root") + + chain = ChainedAction( + name="deploy-flow", + actions=[ + Action("step-one", lambda: "one"), + Action("step-two", lambda: "two"), + ], + ) + command = flx.add_command("D", "Deploy", action=chain) + + command._inject_options_manager() + + assert flx._executor.options_manager is flx.options_manager + assert flx.exit_command.options_manager is flx.options_manager + assert flx.help_command.options_manager is flx.options_manager + + if flx.history_command: + assert flx.history_command.options_manager is flx.options_manager + assert flx.history_command.arg_parser.options_manager is flx.options_manager + + for builtin in flx.builtins.values(): + assert builtin.options_manager is flx.options_manager + if builtin.arg_parser: + assert builtin.arg_parser.options_manager is flx.options_manager + + assert command.options_manager is flx.options_manager + assert command.arg_parser.options_manager is flx.options_manager + + assert chain.options_manager is flx.options_manager + for child_action in chain.actions: + assert child_action.options_manager is flx.options_manager + + +def test_nested_namespace_may_keep_distinct_options_manager_if_intended(): + root_options = OptionsManager() + child_options = OptionsManager() + + root = Falyx(program="root", options_manager=root_options) + child = Falyx(program="child", options_manager=child_options) + child_command = child.add_command("D", "Deploy", action=lambda: "ok") + + root.add_submenu( + key="C", + description="Child Menu", + submenu=child, + ) + + assert root.options_manager is root_options + assert child.options_manager is child_options + assert root.options_manager is not child.options_manager + + assert root._executor.options_manager is root_options + assert child._executor.options_manager is child_options + + assert child_command.options_manager is child_options + assert child_command.arg_parser.options_manager is child_options + + assert child.exit_command.options_manager is child_options + assert child.help_command.options_manager is child_options + if child.history_command: + assert child.history_command.options_manager is child_options + + assert root.namespaces["C"].namespace is child + + +@pytest.mark.asyncio +async def test_nested_namespace_receives_temporary_root_overrides_during_routed_execution(): + root_options = OptionsManager() + child_options = OptionsManager() + + root = Falyx(program="root", options_manager=root_options) + child = Falyx(program="child", options_manager=child_options) + child.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"]) + + child.options_manager.set("verbose", False, "root") + + root.add_submenu( + key="C", + description="Child Menu", + submenu=child, + ) + + seen_during_dispatch = {} + + async def fake_dispatch_route(*, route, args, kwargs, execution_args, **_): + assert route.namespace is child + seen_during_dispatch["verbose"] = route.namespace.options_manager.get( + "verbose", False, "root" + ) + assert seen_during_dispatch["verbose"] is True + return "ok" + + root._dispatch_route = fake_dispatch_route + + result = await root.execute_command("--verbose C D") + + assert result == "ok" + assert seen_during_dispatch["verbose"] is True + + result = await root.execute_command("C --verbose D") + + assert result == "ok" + assert seen_during_dispatch["verbose"] is True + + assert child.options_manager is child_options + assert child.options_manager.get("verbose", False, "root") is False + assert root.options_manager is root_options + + +@pytest.mark.asyncio +async def test_execute_command_applies_root_defaults_without_overwriting_existing_root_values(): + child = Falyx(program="child") + child.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"]) + + child.options_manager.set("verbose", True, "root") + + root = Falyx(program="root") + root.add_submenu( + key="C", + description="Child Menu", + submenu=child, + ) + + async def fake_dispatch_route(*, route, args, kwargs, execution_args, **_): + assert route.namespace is child + assert route.root_overrides == {} + assert route.root_defaults["verbose"] is False + assert route.namespace.options_manager.get("verbose", False, "root") is True + return "ok" + + root._dispatch_route = fake_dispatch_route + + result = await root.execute_command("C D") + + assert result == "ok" + assert child.options_manager.get("verbose", False, "root") is True + + +@pytest.mark.asyncio +async def test_execute_command_applies_root_overrides_temporarily_and_restores_root_namespace(): + child = Falyx(program="child") + child.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"]) + + child.options_manager.set("verbose", False, "root") + + root = Falyx(program="root") + root.add_submenu( + key="C", + description="Child Menu", + submenu=child, + ) + + seen_during_dispatch = {} + + async def fake_dispatch_route(*, route, args, kwargs, execution_args, **_): + assert route.namespace is child + assert route.root_overrides == {"verbose": True} + seen_during_dispatch["verbose"] = route.namespace.options_manager.get( + "verbose", False, "root" + ) + assert seen_during_dispatch["verbose"] is True + return "ok" + + root._dispatch_route = fake_dispatch_route + + result = await root.execute_command("--verbose C D") + + assert result == "ok" + assert seen_during_dispatch["verbose"] is True + + assert child.options_manager.get("verbose", False, "root") is False diff --git a/tests/test_falyx/test_prompt_contract.py b/tests/test_falyx/test_prompt_contract.py new file mode 100644 index 0000000..57295f4 --- /dev/null +++ b/tests/test_falyx/test_prompt_contract.py @@ -0,0 +1,21 @@ +from falyx.action import Action +from falyx.command import Command + + +async def test_action_local_never_prompt_bypasses_command_confirmation(monkeypatch): + called = False + + async def fake_confirm(*args, **kwargs): + nonlocal called + called = True + return True + + monkeypatch.setattr("falyx.command.confirm_async", fake_confirm) + + action = Action("Do Thing", lambda: "ok", never_prompt=True) + command = Command.build("D", "Do Thing", action=action, confirm=True) + + result = await command() + + assert result == "ok" + assert called is False diff --git a/tests/test_falyx/test_routing_contract.py b/tests/test_falyx/test_routing_contract.py new file mode 100644 index 0000000..0e83ea7 --- /dev/null +++ b/tests/test_falyx/test_routing_contract.py @@ -0,0 +1,92 @@ +import pytest + +from falyx import Falyx +from falyx.routing import RouteKind + + +@pytest.mark.asyncio +async def test_resolve_route_carries_root_options_through_nested_namespace(): + child = Falyx(program="child") + child.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"]) + + root = Falyx(program="root") + root.add_submenu( + key="C", + description="Child Menu", + submenu=child, + ) + + route = await root.resolve_route( + ["--verbose", "C", "D"], + invocation_context=root.get_current_invocation_context(), + ) + + assert route.context.typed_path[-2:] == ["C", "D"] + + assert route.kind is RouteKind.COMMAND + assert route.namespace is child + assert route.command is child.commands["D"] + assert route.leaf_argv == [] + + assert route.root_overrides == {"verbose": True} + assert route.root_defaults["verbose"] is False + assert route.root_defaults["debug_hooks"] is False + assert route.root_defaults["never_prompt"] is False + + assert route.namespace_overrides == {} + + +@pytest.mark.asyncio +async def test_resolve_route_returns_unknown_when_only_namespace_options_are_provided(): + flx = Falyx(program="falyx") + flx.add_option("--profile", default="dev") + + route = await flx.resolve_route( + ["--profile", "prod"], + invocation_context=flx.get_current_invocation_context(), + ) + + assert route.kind is RouteKind.UNKNOWN + assert route.namespace is flx + assert route.command is None + assert route.current_head == "" + assert route.is_preview is False + assert route.root_defaults == {} + assert route.root_overrides == {} + assert route.namespace_defaults == {} + assert route.namespace_overrides == {} + + +@pytest.mark.asyncio +async def test_resolve_route_returns_unknown_when_only_root_options_are_provided(): + flx = Falyx(program="falyx") + + route = await flx.resolve_route( + ["--verbose"], + invocation_context=flx.get_current_invocation_context(), + ) + + assert route.kind is RouteKind.UNKNOWN + assert route.namespace is flx + assert route.command is None + assert route.current_head == "" + assert route.is_preview is False + + +@pytest.mark.asyncio +async def test_resolve_route_returns_unknown_when_nested_namespace_consumes_only_options(): + child = Falyx(program="child") + child.add_option("--region", default="us-east") + + root = Falyx(program="root") + root.add_submenu(key="C", description="Child", submenu=child) + + route = await root.resolve_route( + ["C", "--region", "us-west"], + invocation_context=root.get_current_invocation_context(), + ) + + assert route.kind is RouteKind.UNKNOWN + assert route.namespace is child + assert route.command is None + assert route.context.typed_path[-1] == "C" diff --git a/tests/test_falyx/test_run.py b/tests/test_falyx/test_run.py index f599fb6..b12e88d 100644 --- a/tests/test_falyx/test_run.py +++ b/tests/test_falyx/test_run.py @@ -7,7 +7,6 @@ from rich.text import Text from falyx import Falyx from falyx.console import console as falyx_console from falyx.exceptions import FalyxError -from falyx.parser import ParseResult from falyx.signals import BackSignal, CancelSignal, FlowSignal, HelpSignal, QuitSignal @@ -107,27 +106,25 @@ async def test_run_default_to_menu_help(flx): async def test_run_debug_hooks(flx): sys.argv = ["falyx", "--debug-hooks", "T"] - assert flx.options.get("debug_hooks") is False + assert flx.options_manager.get("debug_hooks", namespace_name="root") is False with pytest.raises(SystemExit): await flx.run() - assert flx.options.get("debug_hooks") is True + assert flx.options_manager.get("debug_hooks", namespace_name="root") is False @pytest.mark.asyncio async def test_run_never_prompt(flx): sys.argv = ["falyx", "--never-prompt", "T"] - assert flx.options.get("never_prompt") is False + assert flx.options_manager.get("never_prompt", namespace_name="root") is False with pytest.raises(SystemExit): await flx.run() - falyx_console.print(flx.options.get_namespace_dict("default")) - - assert flx.options.get("debug_hooks") is False - assert flx.options.get("never_prompt") is True + assert flx.options_manager.get("debug_hooks", namespace_name="root") is False + assert flx.options_manager.get("never_prompt", namespace_name="root") is False @pytest.mark.asyncio @@ -253,3 +250,70 @@ async def test_run_preview(flx): captured = Text.from_ansi(capture.get()).plain assert "Command: 'T'" in captured assert "Would call: (args=(), kwargs={})" in captured + + +@pytest.mark.asyncio +async def test_run_applies_root_defaults_without_overwriting_existing_root_values(): + child = Falyx(program="child") + child.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"]) + + child.options_manager.set("verbose", True, "root") + + root = Falyx(program="root") + root.add_submenu( + key="C", + description="Child Menu", + submenu=child, + ) + + async def fake_dispatch_route(*, route, args, kwargs, execution_args, **_): + assert route.namespace is child + assert route.root_overrides == {} + assert route.root_defaults["verbose"] is False + assert route.namespace.options_manager.get("verbose", False, "root") is True + + root._dispatch_route = fake_dispatch_route + + sys.argv = ["falyx", "C", "D"] + with pytest.raises(SystemExit) as excinfo: + await root.run() + + assert excinfo.value.code == 0 + + assert child.options_manager.get("verbose", False, "root") is True + + +@pytest.mark.asyncio +async def test_run_applies_root_overrides_temporarily_and_restores_root_namespace(): + child = Falyx(program="child") + child.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"]) + + child.options_manager.set("verbose", False, "root") + + root = Falyx(program="root") + root.add_submenu( + key="C", + description="Child Menu", + submenu=child, + ) + + seen_during_dispatch = {} + + async def fake_dispatch_route(*, route, args, kwargs, execution_args, **_): + seen_during_dispatch["verbose"] = route.namespace.options_manager.get( + "verbose", False, "root" + ) + assert route.namespace is child + assert route.root_overrides == {"verbose": True} + assert seen_during_dispatch["verbose"] is True + + root._dispatch_route = fake_dispatch_route + + sys.argv = ["falyx", "--verbose", "C", "D"] + with pytest.raises(SystemExit) as excinfo: + await root.run() + + assert excinfo.value.code == 0 + assert seen_during_dispatch["verbose"] is True + + assert child.options_manager.get("verbose", False, "root") is False diff --git a/tests/test_falyx/test_signals.py b/tests/test_falyx/test_signals.py new file mode 100644 index 0000000..307dfcf --- /dev/null +++ b/tests/test_falyx/test_signals.py @@ -0,0 +1,15 @@ +import sys + +import pytest + +from falyx import Falyx + + +async def test_run_quit_signal_exits_130(monkeypatch): + flx = Falyx(default_to_menu=False) + monkeypatch.setattr(sys, "argv", ["prog", "X"]) + + with pytest.raises(SystemExit) as exc: + await flx.run() + + assert exc.value.code == 130 diff --git a/tests/test_falyx_parser/test_falyx_parser.py b/tests/test_falyx_parser/test_falyx_parser.py new file mode 100644 index 0000000..3c28a52 --- /dev/null +++ b/tests/test_falyx_parser/test_falyx_parser.py @@ -0,0 +1,900 @@ +from __future__ import annotations + +import pytest + +from falyx import Falyx +from falyx.exceptions import EntryNotFoundError, FalyxOptionError +from falyx.mode import FalyxMode +from falyx.parser.falyx_parser import FalyxParser +from falyx.parser.option import Option +from falyx.parser.parser_types import FalyxTLDRExample + + +@pytest.fixture +def flx() -> Falyx: + flx = Falyx() + flx.add_command( + "D", + description="Deploy command", + action=lambda: "deploy", + aliases=["deploy"], + ) + return flx + + +@pytest.fixture +def parser(flx: Falyx) -> FalyxParser: + return FalyxParser(flx) + + +def test_init_registers_reserved_options_by_default(parser: FalyxParser) -> None: + flags = parser.get_flags() + + assert "-h" in flags + assert "-v" in flags + assert "-d" in flags + assert "-n" in flags + assert parser.help_option is not None + assert parser.tldr_option is None + + +def test_init_respects_disabled_reserved_root_options() -> None: + parser = FalyxParser( + Falyx( + disable_verbose_option=True, + disable_debug_hooks_option=True, + disable_never_prompt_option=True, + ) + ) + + assert parser.get_flags() == ["-h"] + + with pytest.raises(FalyxOptionError, match="unknown option '-v'"): + parser.parse_args(["-v"]) + + +def test_get_options_returns_registered_options(parser: FalyxParser) -> None: + parser.add_option("--region", "-r", default="us-east") + + options = parser.get_options() + + assert any(option.dest == "region" for option in options) + assert parser.get_flags()[-1] == "--region" + + +def test_add_option_registers_store_option_with_default_and_choices( + parser: FalyxParser, +) -> None: + parser.add_option( + "--region", + "-r", + default="us-east", + choices=["us-east", "us-west"], + ) + + result = parser.parse_args(["--region", "us-west", "deploy"]) + + assert result.namespace_options["region"] == "us-west" + assert result.namespace_defaults["region"] == "us-east" + assert result.remaining_argv == ["deploy"] + + +def test_add_option_infers_dest_from_long_flag(parser: FalyxParser) -> None: + parser.add_option("--dry-run-mode", default="safe") + + result = parser.parse_args([]) + + assert result.namespace_defaults["dry_run_mode"] == "safe" + + +def test_add_option_uses_explicit_dest(parser: FalyxParser) -> None: + parser.add_option("--profile-name", dest="profile", default="dev") + + result = parser.parse_args(["--profile-name", "prod"]) + + assert result.namespace_options["profile"] == "prod" + + +@pytest.mark.parametrize( + ("flags", "match"), + [ + ((), "no flags provided"), + (("region",), "must start with '-'"), + (("--",), "long flags must have at least one character"), + (("-abc",), "short flags must be a single character"), + ], +) +def test_add_option_rejects_invalid_flags( + parser: FalyxParser, + flags: tuple[str, ...], + match: str, +) -> None: + with pytest.raises(FalyxOptionError, match=match): + parser.add_option(*flags) + + +@pytest.mark.parametrize("dest", ["help", "tldr"]) +def test_add_option_rejects_reserved_dests( + parser: FalyxParser, + dest: str, +) -> None: + with pytest.raises(FalyxOptionError, match="reserved"): + parser.add_option("--custom", dest=dest) + + +def test_add_option_rejects_duplicate_dest(parser: FalyxParser) -> None: + parser.add_option("--region") + + with pytest.raises(FalyxOptionError, match="duplicate option dest 'region'"): + parser.add_option("--region-name", dest="region") + + +def test_add_option_rejects_duplicate_flag(parser: FalyxParser) -> None: + parser.add_option("--region") + + with pytest.raises(FalyxOptionError, match="already used"): + parser.add_option("--region", dest="other_region") + + +@pytest.mark.parametrize( + ("dest", "match"), + [ + ("bad-dest", "valid identifier"), + ("1bad", "cannot start with a digit"), + ], +) +def test_add_option_rejects_invalid_explicit_dest( + parser: FalyxParser, + dest: str, + match: str, +) -> None: + with pytest.raises(FalyxOptionError, match=match): + parser.add_option("--valid", dest=dest) + + +def test_add_option_rejects_invalid_action(parser: FalyxParser) -> None: + with pytest.raises(FalyxOptionError, match="invalid option action"): + parser.add_option("--region", action="not-real") + + +def test_add_option_rejects_invalid_store_true_default(parser: FalyxParser) -> None: + with pytest.raises(FalyxOptionError, match="must be False or None"): + parser.add_option("--foo", action="store_true", default=True) + + +def test_add_option_rejects_invalid_store_false_default(parser: FalyxParser) -> None: + with pytest.raises(FalyxOptionError, match="must be True or None"): + parser.add_option("--foo", action="store_false", default=False) + + +def test_add_option_rejects_invalid_store_bool_optional_default( + parser: FalyxParser, +) -> None: + with pytest.raises( + FalyxOptionError, + match="default value for 'store_bool_optional' action must be None", + ): + parser.add_option("--foo", action="store_bool_optional", default="not-bool") + + +def test_add_option_rejects_default_for_help_or_tldr_option(parser: FalyxParser) -> None: + with pytest.raises( + FalyxOptionError, match="default value cannot be set for action 'help'" + ): + parser.add_option("--additional-help", action="help", default=True) + + with pytest.raises( + FalyxOptionError, match="default value cannot be set for action 'tldr'" + ): + parser.add_option("--more-tldr", action="tldr", default=True) + + +def test_add_option_rejects_choices_for_boolean_option(parser: FalyxParser) -> None: + with pytest.raises(FalyxOptionError, match="choices cannot be specified"): + parser.add_option("--foo", action="store_true", choices=["yes"]) + + +def test_add_option_rejects_default_outside_choices(parser: FalyxParser) -> None: + with pytest.raises(FalyxOptionError, match="not in allowed choices"): + parser.add_option( + "--region", + default="eu-central", + choices=["us-east", "us-west"], + ) + + +def test_add_option_rejects_invalid_default_type(parser: FalyxParser) -> None: + with pytest.raises(FalyxOptionError, match="cannot be coerced to int"): + parser.add_option("--retries", type=int, default="not-an-int") + + +def test_add_option_rejects_invalid_choice_type(parser: FalyxParser) -> None: + with pytest.raises(FalyxOptionError, match="invalid choice"): + parser.add_option("--retries", type=int, choices=["1", "bad"]) + + +def test_add_option_rejects_non_list_suggestions(parser: FalyxParser) -> None: + with pytest.raises(FalyxOptionError, match="suggestions must be a list or None"): + parser.add_option("--profile", suggestions=("dev", "prod")) + + +def test_add_option_rejects_non_string_suggestions(parser: FalyxParser) -> None: + with pytest.raises(FalyxOptionError, match="suggestions must be a list of strings"): + parser.add_option("--profile", suggestions=["dev", 1]) + + +def test_add_option_rejects_non_string_flags(parser: FalyxParser) -> None: + with pytest.raises(FalyxOptionError, match="invalid flag '123': must be a string"): + parser.add_option("--region", 123) + + +def test_add_option_rejects_flags_with_invalid_prefix(parser: FalyxParser) -> None: + with pytest.raises( + FalyxOptionError, match="invalid flag 'region': must start with '-'" + ): + parser.add_option("region") + + +def test_add_option_rejects_long_flag_with_insufficient_length( + parser: FalyxParser, +) -> None: + with pytest.raises( + FalyxOptionError, match="long flags must have at least one character after '--'" + ): + parser.add_option("--", dest="invalid") + + +def test_add_option_rejects_speacial_characters_in_dest(parser: FalyxParser) -> None: + with pytest.raises( + FalyxOptionError, match="invalid dest 'bad-dest': must be a valid identifier" + ): + parser.add_option("--bad-dest", dest="bad-dest") + + with pytest.raises( + FalyxOptionError, match=r"invalid dest 'bad\*dest': must be a valid identifier" + ): + parser.add_option("--bad-dest", dest="bad*dest") + + with pytest.raises( + FalyxOptionError, match="invalid dest '1bad-dest': must be a valid identifier" + ): + parser.add_option("--bad-dest", dest="1bad-dest") + + +def test_add_option_rejects_dest_starting_with_digit(parser: FalyxParser) -> None: + with pytest.raises( + FalyxOptionError, match="invalid dest '1bad': cannot start with a digit" + ): + parser.add_option("--1bad", dest="1bad") + + +def test_add_option_rejects_special_characters_in_flags(parser: FalyxParser) -> None: + with pytest.raises( + FalyxOptionError, + match=r"invalid flag '--bad\*flag': must only contain letters, digits, underscores, or hyphens", + ): + parser.add_option("--bad*flag", dest="bad_flag") + + +def test_add_option_rejects_short_flag_with_multiple_characters( + parser: FalyxParser, +) -> None: + with pytest.raises( + FalyxOptionError, + match="invalid flag '-ab': short flags must be a single character", + ): + parser.add_option("-ab") + + +def test_add_option_rejects_bad_flags(parser: FalyxParser) -> None: + with pytest.raises( + FalyxOptionError, + match="--region1@': must only contain letters, digits, underscores, or hyphens", + ): + parser.add_option("--region1@") + + with pytest.raises( + FalyxOptionError, match="invalid dest '42region': cannot start with a digit" + ): + parser.add_option("--42region") + + +def test_register_option_rejects_duplicate_flag(parser: FalyxParser) -> None: + parser.add_option("--region", "-r") + parser.add_option("--profile", "-p") + + option1 = Option(flags=("--region", "-r"), dest="region") + option2 = Option(flags=("--profile", "-p"), dest="profile") + + with pytest.raises(FalyxOptionError, match="already used"): + parser._register_option(option1) + + with pytest.raises(FalyxOptionError, match="already used"): + parser._register_option(option2) + + +def test_parse_args_with_no_args_returns_defaults(parser: FalyxParser) -> None: + result = parser.parse_args([]) + + assert result.mode is FalyxMode.COMMAND + assert result.raw_argv == [] + assert result.remaining_argv == [] + assert result.current_head == "" + assert result.help is False + assert result.tldr is False + assert result.namespace_defaults["help"] is False + assert result.root_defaults["verbose"] is False + assert result.root_defaults["debug_hooks"] is False + assert result.root_defaults["never_prompt"] is False + + +def test_parse_args_splits_root_and_namespace_options(parser: FalyxParser) -> None: + parser.add_option("--profile", default="dev") + + result = parser.parse_args(["--verbose", "--profile", "prod", "deploy"]) + + assert result.root_options == {"verbose": True} + assert result.namespace_options == {"profile": "prod"} + assert result.remaining_argv == ["deploy"] + assert result.current_head == "deploy" + + +@pytest.mark.parametrize("help_flag", ["-h", "--help"]) +def test_parse_args_help_flag_sets_help_mode( + parser: FalyxParser, + help_flag: str, +) -> None: + result = parser.parse_args([help_flag]) + + assert result.mode is FalyxMode.HELP + assert result.help is True + assert result.namespace_options["help"] is True + assert result.remaining_argv == [] + + +def test_parse_args_tldr_flag_sets_help_mode_after_tldr_registered( + parser: FalyxParser, +) -> None: + parser.add_tldr_example( + entry_key="D", + usage="--region us-east", + description="Deploy to us-east", + ) + + result = parser.parse_args(["--tldr"]) + + assert result.mode is FalyxMode.HELP + assert result.tldr is True + assert result.namespace_options["tldr"] is True + + +def test_parse_args_unknown_leading_option_raises(parser: FalyxParser) -> None: + with pytest.raises(FalyxOptionError, match="unknown option '--wat'"): + parser.parse_args(["--wat"]) + + +def test_parse_args_stops_at_first_non_option_boundary(parser: FalyxParser) -> None: + result = parser.parse_args(["deploy", "--verbose", "--never-prompt"]) + + assert result.root_options == {} + assert result.namespace_options == {} + assert result.remaining_argv == ["deploy", "--verbose", "--never-prompt"] + assert result.current_head == "deploy" + + +def test_parse_args_allows_unknown_options_after_route_boundary( + parser: FalyxParser, +) -> None: + result = parser.parse_args(["deploy", "--command-local-option"]) + + assert result.remaining_argv == ["deploy", "--command-local-option"] + + +def test_parse_args_store_true_and_store_false(parser: FalyxParser) -> None: + parser.add_option("--json", action="store_true") + parser.add_option("--color", action="store_false") + + result = parser.parse_args(["--json", "--color"]) + + assert result.namespace_defaults["json"] is False + assert result.namespace_defaults["color"] is True + assert result.namespace_options["json"] is True + assert result.namespace_options["color"] is False + + +def test_parse_args_count_option(parser: FalyxParser) -> None: + parser.add_option("-q", "--quiet", action="count") + + result = parser.parse_args(["-q", "-q", "--quiet"]) + + assert result.namespace_defaults["quiet"] == 0 + assert result.namespace_options["quiet"] == 3 + + +def test_parse_args_posix_bundles_boolean_and_count_options( + parser: FalyxParser, +) -> None: + parser.add_option("-q", "--quiet", action="count") + + result = parser.parse_args(["-vdnq", "deploy"]) + + assert result.root_options == { + "verbose": True, + "debug_hooks": True, + "never_prompt": True, + } + assert result.namespace_options == {"quiet": 1} + assert result.remaining_argv == ["deploy"] + + +def test_parse_args_posix_bundle_can_end_with_store_option( + parser: FalyxParser, +) -> None: + parser.add_option("-q", "--quiet", action="count") + parser.add_option("-r", "--region") + + result = parser.parse_args(["-qr", "us-east", "deploy"]) + + assert result.namespace_options == { + "quiet": 1, + "region": "us-east", + } + assert result.remaining_argv == ["deploy"] + + +def test_parse_args_does_not_expand_invalid_posix_bundle(parser: FalyxParser) -> None: + with pytest.raises(FalyxOptionError, match="unknown option '-vz'"): + parser.parse_args(["-vz"]) + + +def test_parse_args_store_option_requires_value(parser: FalyxParser) -> None: + parser.add_option("--region") + + with pytest.raises(FalyxOptionError, match="expected a value"): + parser.parse_args(["--region"]) + + +def test_parse_args_store_option_coerces_value(parser: FalyxParser) -> None: + parser.add_option("--retries", type=int) + + result = parser.parse_args(["--retries", "3"]) + + assert result.namespace_options["retries"] == 3 + + +def test_parse_args_store_option_rejects_invalid_value(parser: FalyxParser) -> None: + parser.add_option("--retries", type=int) + + with pytest.raises(FalyxOptionError, match="invalid value for '--retries'"): + parser.parse_args(["--retries", "abc"]) + + +def test_parse_args_store_option_rejects_value_outside_choices( + parser: FalyxParser, +) -> None: + parser.add_option("--region", choices=["us-east", "us-west"]) + + with pytest.raises(FalyxOptionError, match="expected one of"): + parser.parse_args(["--region", "eu-central"]) + + +def test_suggest_next_returns_no_suggestions_for_empty_args( + parser: FalyxParser, +) -> None: + suggestions, expecting_value = parser.suggest_next([], cursor_at_end_of_token=False) + + assert suggestions == [] + assert expecting_value is False + + +def test_suggest_next_suggests_matching_option_flags(parser: FalyxParser) -> None: + parser.add_option("--region", "-r") + + suggestions, expecting_value = parser.suggest_next( + ["--r"], + cursor_at_end_of_token=False, + ) + + assert suggestions == ["--region"] + assert expecting_value is False + + +def test_suggest_next_suggests_all_remaining_flags_at_token_boundary( + parser: FalyxParser, +) -> None: + parser.add_option("--region", "-r") + + suggestions, expecting_value = parser.suggest_next( + ["--"], + cursor_at_end_of_token=False, + ) + + assert "--help" in suggestions + assert "--verbose" in suggestions + assert "--debug-hooks" in suggestions + assert "--never-prompt" in suggestions + assert "--region" in suggestions + assert expecting_value is False + + +def test_suggest_next_suggests_choice_values_after_store_option( + parser: FalyxParser, +) -> None: + parser.add_option("--region", choices=["us-east", "us-west"]) + + suggestions, expecting_value = parser.suggest_next( + ["--region"], + cursor_at_end_of_token=True, + ) + + assert suggestions == ["us-east", "us-west"] + assert expecting_value is True + + +def test_suggest_next_filters_choice_values_by_prefix(parser: FalyxParser) -> None: + parser.add_option("--region", choices=["us-east", "us-west", "eu-central"]) + + suggestions, expecting_value = parser.suggest_next( + ["--region", "us-e"], + cursor_at_end_of_token=False, + ) + + assert suggestions == ["us-east"] + assert expecting_value is True + + +def test_suggest_next_uses_custom_value_suggestions(parser: FalyxParser) -> None: + parser.add_option("--profile", suggestions=["dev", "prod", "staging"]) + + suggestions, expecting_value = parser.suggest_next( + ["--profile", "pr"], + cursor_at_end_of_token=False, + ) + + assert suggestions == ["prod"] + assert expecting_value is True + + +def test_suggest_next_excludes_consumed_options(parser: FalyxParser) -> None: + parser.add_option("--region", choices=["us-east", "us-west"]) + + parser.parse_args(["--region", "us-east"]) + suggestions, expecting_value = parser.suggest_next( + ["-"], + cursor_at_end_of_token=False, + ) + + assert "--region" not in suggestions + assert "-r" not in suggestions + assert expecting_value is False + + +def test_add_tldr_example_registers_example_and_tldr_option( + parser: FalyxParser, +) -> None: + parser.add_tldr_example( + entry_key="D", + usage="--region us-east", + description="Deploy to us-east", + ) + + assert parser.tldr_option is not None + assert "--tldr" in parser._options_by_dest + assert parser._tldr_examples == [ + FalyxTLDRExample( + entry_key="D", + usage="--region us-east", + description="Deploy to us-east", + ) + ] + + +def test_add_tldr_example_rejects_unknown_entry(parser: FalyxParser) -> None: + with pytest.raises(EntryNotFoundError) as error: + parser.add_tldr_example( + entry_key="depoy", + usage="", + description="Typo example", + ) + + assert error.value.unknown_name == "depoy" + assert error.value.suggestions == ["DEPLOY"] + + +def test_add_tldr_examples_accepts_dataclass_instances( + parser: FalyxParser, +) -> None: + example = FalyxTLDRExample( + entry_key="deploy", + usage="--region us-east", + description="Deploy to us-east", + ) + + parser.add_tldr_examples([example]) + + assert parser.tldr_option is not None + assert parser._tldr_examples == [example] + + +def test_add_tldr_examples_accepts_three_tuple_examples( + parser: FalyxParser, +) -> None: + parser.add_tldr_examples( + [ + ("deploy", "--region us-east", "Deploy to us-east"), + ] + ) + + assert parser.tldr_option is not None + assert parser._tldr_examples == [ + FalyxTLDRExample( + entry_key="deploy", + usage="--region us-east", + description="Deploy to us-east", + ) + ] + + +def test_add_tldr_examples_rejects_invalid_tuple_shape( + parser: FalyxParser, +) -> None: + with pytest.raises(FalyxOptionError, match="invalid TLDR example format"): + parser.add_tldr_examples([("deploy", "missing description")]) + + +def test_add_tldr_examples_rejects_unknown_entry(parser: FalyxParser) -> None: + with pytest.raises(EntryNotFoundError) as error: + parser.add_tldr_examples( + [ + ("depoy", "--region us-east", "Typo example"), + ] + ) + + with pytest.raises(EntryNotFoundError) as error: + parser.add_tldr_examples( + [ + FalyxTLDRExample( + entry_key="depoy", + usage="--region us-east", + description="Typo example", + ) + ] + ) + + assert error.value.unknown_name == "depoy" + assert error.value.suggestions == ["DEPLOY"] + + +def test_store_bool_optional_registers_positive_and_negative_flags( + parser: FalyxParser, +) -> None: + parser.add_option("--cache", action="store_bool_optional") + + assert "--cache" in parser._options_by_dest + assert "--no-cache" in parser._options_by_dest + + result = parser.parse_args([]) + assert result.namespace_defaults["cache"] is None + + result = parser.parse_args(["--cache"]) + assert result.namespace_options["cache"] is True + + result = parser.parse_args(["--no-cache"]) + assert result.namespace_options["cache"] is False + + +@pytest.mark.parametrize( + ("flag", "expected"), + [ + ("--cache", True), + ("--no-cache", False), + ], +) +def test_parse_args_store_bool_optional_intended_behavior( + parser: FalyxParser, + flag: str, + expected: bool, +) -> None: + parser.add_option("--cache", action="store_bool_optional") + + result = parser.parse_args([flag]) + + assert result.namespace_options["cache"] is expected + + +def test_parse_args_store_bool_optional_rejects_multiple_flags( + parser: FalyxParser, +) -> None: + with pytest.raises( + FalyxOptionError, match="store_bool_optional action can only have a single flag" + ): + parser.add_option("--cache", "-c", action="store_bool_optional") + + +def test_parse_args_store_bool_optional_rejects_short_flags(parser: FalyxParser) -> None: + with pytest.raises( + FalyxOptionError, match="store_bool_optional action must use a long flag" + ): + parser.add_option("-c", action="store_bool_optional") + + +def test_parse_args_long_root_flags(parser: FalyxParser) -> None: + result = parser.parse_args(["--verbose", "--debug-hooks", "--never-prompt", "deploy"]) + + assert result.root_options == { + "verbose": True, + "debug_hooks": True, + "never_prompt": True, + } + assert result.namespace_options == {} + assert result.remaining_argv == ["deploy"] + + +@pytest.mark.parametrize("help_flag", ["-h", "--help"]) +def test_parse_args_forwards_help_after_route_boundary( + parser: FalyxParser, + help_flag: str, +) -> None: + result = parser.parse_args(["deploy", help_flag]) + + assert result.mode is FalyxMode.COMMAND + assert result.help is False + assert result.namespace_options == {} + assert result.remaining_argv == ["deploy", help_flag] + + +def test_parse_args_short_tldr_flag_sets_help_mode_after_tldr_registered( + parser: FalyxParser, +) -> None: + parser.add_tldr_example( + entry_key="D", + usage="--region us-east", + description="Deploy to us-east", + ) + + result = parser.parse_args(["-T"]) + + assert result.mode is FalyxMode.HELP + assert result.tldr is True + assert result.namespace_options["tldr"] is True + + +def test_add_tldr_examples_registers_tldr_option_only_once( + parser: FalyxParser, +) -> None: + parser.add_tldr_example( + entry_key="deploy", + usage="--region us-east", + description="Deploy to us-east", + ) + parser.add_tldr_example( + entry_key="deploy", + usage="--region us-west", + description="Deploy to us-west", + ) + + tldr_options = [option for option in parser.get_options() if option.dest == "tldr"] + + assert len(tldr_options) == 1 + assert parser.get_flags().count("--tldr") == 1 + assert len(parser._tldr_examples) == 2 + + +def test_parse_args_resets_consumed_option_state_between_parses( + parser: FalyxParser, +) -> None: + parser.add_option("--region", "-r", choices=["us-east", "us-west"]) + + parser.parse_args(["--region", "us-east"]) + suggestions, _ = parser.suggest_next(["-"], cursor_at_end_of_token=False) + assert "--region" not in suggestions + + parser.parse_args([]) + suggestions, expecting_value = parser.suggest_next( + ["--r"], + cursor_at_end_of_token=False, + ) + + assert suggestions == ["--region"] + assert expecting_value is False + + +def test_disabled_reserved_root_options_are_omitted_from_defaults() -> None: + parser = FalyxParser( + Falyx( + disable_verbose_option=True, + disable_debug_hooks_option=True, + disable_never_prompt_option=True, + ) + ) + + result = parser.parse_args([]) + + assert result.root_defaults == {} + assert result.namespace_defaults["help"] is False + + +def test_parse_args_typed_choices_are_compared_after_coercion( + parser: FalyxParser, +) -> None: + parser.add_option("--retries", type=int, choices=["1", "2"]) + + result = parser.parse_args(["--retries", "1"]) + + assert result.namespace_options["retries"] == 1 + + +def test_add_option_rejects_dict_choices(parser: FalyxParser) -> None: + with pytest.raises(FalyxOptionError, match="choices cannot be a dict"): + parser.add_option("--region", choices={"east": "us-east"}) + + +def test_add_option_rejects_non_iterable_choices(parser: FalyxParser) -> None: + with pytest.raises(FalyxOptionError, match="choices must be iterable"): + parser.add_option("--region", choices=1) + + +@pytest.mark.parametrize("flag", ["--verbose", "--debug-hooks", "--never-prompt"]) +def test_suggest_next_does_not_expect_value_for_root_boolean_flags( + parser: FalyxParser, + flag: str, +) -> None: + suggestions, expecting_value = parser.suggest_next( + [flag], + cursor_at_end_of_token=True, + ) + + assert suggestions == [] + assert expecting_value is False + + +def test_add_option_normalizes_typed_choices(parser: FalyxParser) -> None: + option = parser.add_option("--retries", type=int, choices=["1", "2"]) + + assert option.choices == [1, 2] + + +def test_parse_args_accepts_value_matching_normalized_typed_choice( + parser: FalyxParser, +) -> None: + parser.add_option("--retries", type=int, choices=["1", "2"]) + + result = parser.parse_args(["--retries", "1"]) + + assert result.namespace_options["retries"] == 1 + + +def test_add_option_normalizes_typed_default_before_choice_check( + parser: FalyxParser, +) -> None: + parser.add_option("--retries", type=int, choices=["1", "2"], default="1") + + result = parser.parse_args([]) + + assert result.namespace_defaults["retries"] == 1 + + +def test_add_option_returns_registered_option(parser: FalyxParser) -> None: + option = parser.add_option( + "--retries", + type=int, + default="1", + choices=["1", "2"], + ) + + assert isinstance(option, Option) + assert option.dest == "retries" + assert option.default == 1 + assert option.choices == [1, 2] + assert option in parser.get_options() + + +def test_add_option_store_bool_optional_returns_primary_option( + parser: FalyxParser, +) -> None: + option = parser.add_option("--cache", action="store_bool_optional") + + assert option.dest == "cache" + assert option.flags == ("--cache",) + assert "--cache" in parser._options_by_dest + assert "--no-cache" in parser._options_by_dest diff --git a/tests/test_hook_manager.py b/tests/test_hook_manager.py new file mode 100644 index 0000000..f6d150f --- /dev/null +++ b/tests/test_hook_manager.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest + +from falyx.hook_manager import HookManager, HookType + + +def make_context(*, name: str = "DemoAction", exception: Exception | None = None) -> Any: + return SimpleNamespace(name=name, exception=exception, events=[]) + + +def test_hook_type_choices_aliases_and_string_representation() -> None: + assert HookType.choices() == [ + HookType.BEFORE, + HookType.ON_SUCCESS, + HookType.ON_ERROR, + HookType.AFTER, + HookType.ON_TEARDOWN, + ] + assert HookType(" before ") is HookType.BEFORE + assert HookType("success") is HookType.ON_SUCCESS + assert HookType(" ERROR ") is HookType.ON_ERROR + assert HookType("teardown") is HookType.ON_TEARDOWN + assert str(HookType.AFTER) == "after" + + +@pytest.mark.parametrize("bad_value", [7, object()]) +def test_hook_type_rejects_non_string_missing_values(bad_value: object) -> None: + with pytest.raises(ValueError, match="Invalid HookType"): + HookType(bad_value) + + +def test_hook_type_rejects_unknown_string_with_valid_choices() -> None: + with pytest.raises(ValueError) as exc_info: + HookType("not-a-hook") + + message = str(exc_info.value) + assert "Invalid HookType: 'not-a-hook'" in message + assert "before" in message + assert "on_success" in message + assert "on_error" in message + assert "after" in message + assert "on_teardown" in message + + +def test_manager_initializes_all_hook_buckets_and_registers_aliases() -> None: + manager = HookManager() + + assert set(manager._hooks) == set(HookType) + assert all(hooks == [] for hooks in manager._hooks.values()) + + def before_hook(context: Any) -> None: + context.events.append("before") + + def success_hook(context: Any) -> None: + context.events.append("success") + + manager.register(HookType.BEFORE, before_hook) + manager.register("success", success_hook) + + assert manager._hooks[HookType.BEFORE] == [before_hook] + assert manager._hooks[HookType.ON_SUCCESS] == [success_hook] + + +def test_register_rejects_invalid_hook_type() -> None: + manager = HookManager() + + def hook(context: Any) -> None: + context.events.append("never-called") + + with pytest.raises(ValueError, match="Invalid HookType"): + manager.register("missing-phase", hook) + + +@pytest.mark.asyncio +async def test_trigger_runs_sync_and_async_hooks_in_registration_order() -> None: + manager = HookManager() + context = make_context() + + def sync_first(ctx: Any) -> None: + ctx.events.append("sync-first") + + async def async_second(ctx: Any) -> None: + ctx.events.append("async-second") + + def sync_third(ctx: Any) -> None: + ctx.events.append("sync-third") + + manager.register("before", sync_first) + manager.register(HookType.BEFORE, async_second) + manager.register("before", sync_third) + + await manager.trigger(HookType.BEFORE, context) + + assert context.events == ["sync-first", "async-second", "sync-third"] + + +@pytest.mark.asyncio +async def test_trigger_rejects_unsupported_runtime_hook_type() -> None: + manager = HookManager() + + with pytest.raises(ValueError, match="Unsupported hook type"): + await manager.trigger("not-a-hook", make_context()) # type: ignore[arg-type] + + +@pytest.mark.asyncio +async def test_trigger_logs_and_continues_after_non_error_hook_failure() -> None: + manager = HookManager() + context = make_context() + + def failing_hook(ctx: Any) -> None: + ctx.events.append("failing") + raise RuntimeError("hook exploded") + + def surviving_hook(ctx: Any) -> None: + ctx.events.append("surviving") + + manager.register(HookType.BEFORE, failing_hook) + manager.register(HookType.BEFORE, surviving_hook) + + await manager.trigger(HookType.BEFORE, context) + + assert context.events == ["failing", "surviving"] + + +@pytest.mark.asyncio +async def test_trigger_on_error_hook_failure_reraises_original_context_exception() -> ( + None +): + manager = HookManager() + original_error = ValueError("original failure") + context = make_context(exception=original_error) + + def failing_error_hook(ctx: Any) -> None: + ctx.events.append("error-hook") + raise RuntimeError("error hook failed") + + manager.register("error", failing_error_hook) + + with pytest.raises(ValueError) as exc_info: + await manager.trigger(HookType.ON_ERROR, context) + + assert exc_info.value is original_error + assert isinstance(exc_info.value.__cause__, RuntimeError) + assert str(exc_info.value.__cause__) == "error hook failed" + assert context.events == ["error-hook"] + + +@pytest.mark.asyncio +async def test_trigger_on_error_requires_context_exception_when_hook_fails() -> None: + manager = HookManager() + context = make_context(exception=None) + + def failing_error_hook(ctx: Any) -> None: + raise RuntimeError("error hook failed") + + manager.register(HookType.ON_ERROR, failing_error_hook) + + with pytest.raises(AssertionError, match="Context exception should be set"): + await manager.trigger(HookType.ON_ERROR, context) + + +def test_clear_removes_one_hook_bucket_or_all_buckets() -> None: + manager = HookManager() + + def before_hook(context: Any) -> None: + context.events.append("before") + + def after_hook(context: Any) -> None: + context.events.append("after") + + manager.register("before", before_hook) + manager.register("after", after_hook) + + manager.clear(HookType.BEFORE) + + assert manager._hooks[HookType.BEFORE] == [] + assert manager._hooks[HookType.AFTER] == [after_hook] + + manager.clear() + + assert all(hooks == [] for hooks in manager._hooks.values()) + + +def test_string_representation_lists_registered_hook_names_and_empty_buckets() -> None: + manager = HookManager() + + def before_hook(context: Any) -> None: + context.events.append("before") + + manager.register("before", before_hook) + + text = str(manager) + + assert text.startswith("") + assert "before: before_hook" in text + assert "on_success: —" in text + assert "on_error: —" in text + assert "after: —" in text + assert "on_teardown: —" in text + + +def test_copy_copies_hook_lists_without_sharing_list_objects() -> None: + manager = HookManager() + + def first_hook(context: Any) -> None: + context.events.append("first") + + def second_hook(context: Any) -> None: + context.events.append("second") + + manager.register("teardown", first_hook) + + clone = manager.copy() + + assert clone is not manager + assert clone._hooks[HookType.ON_TEARDOWN] == [first_hook] + assert clone._hooks[HookType.ON_TEARDOWN] is not manager._hooks[HookType.ON_TEARDOWN] + + clone.register("teardown", second_hook) + + assert manager._hooks[HookType.ON_TEARDOWN] == [first_hook] + assert clone._hooks[HookType.ON_TEARDOWN] == [first_hook, second_hook] diff --git a/tests/test_parsers/test_command_argument_parser.py b/tests/test_parsers/test_command_argument_parser.py index 96a273f..6c0e901 100644 --- a/tests/test_parsers/test_command_argument_parser.py +++ b/tests/test_parsers/test_command_argument_parser.py @@ -5,7 +5,7 @@ from falyx.action import Action from falyx.console import console as falyx_console from falyx.exceptions import CommandArgumentError, NotAFalyxError from falyx.options_manager import OptionsManager -from falyx.parser import ArgumentAction, CommandArgumentParser +from falyx.parser import Argument, ArgumentAction, CommandArgumentParser from falyx.signals import HelpSignal @@ -1009,3 +1009,20 @@ def test_add_argument_invalid_lazy_resolver(): CommandArgumentError, match="lazy_resolver must be a boolean, got int" ): parser.add_argument("--valid", lazy_resolver=123) + + +def test_add_argument_returns_registered_argument() -> None: + parser = CommandArgumentParser() + + arg = parser.add_argument( + "--retries", + type=int, + default="1", + choices=["1", "2"], + ) + + assert isinstance(arg, Argument) + assert arg.dest == "retries" + assert arg.default == 1 + assert arg.choices == [1, 2] + assert parser.get_argument("retries") is arg diff --git a/tests/test_parsers/test_command_argument_parser_clone_contract.py b/tests/test_parsers/test_command_argument_parser_clone_contract.py new file mode 100644 index 0000000..8d4695f --- /dev/null +++ b/tests/test_parsers/test_command_argument_parser_clone_contract.py @@ -0,0 +1,373 @@ +from falyx.console import console +from falyx.execution_option import ExecutionOption +from falyx.options_manager import OptionsManager +from falyx.parser import CommandArgumentParser +from falyx.parser.parser_types import TLDRExample + + +def build_parser() -> CommandArgumentParser: + parser = CommandArgumentParser( + command_key="D", + command_description="Deploy", + help_text="Deploy something.", + help_epilog="More help text.", + aliases=["deploy"], + program="source", + options_manager=OptionsManager(), + ) + + parser.add_argument("--region", choices=["us-east", "us-west"], default="us-east") + parser.add_argument("target") + + group = parser.add_argument_group("auth", description="Authentication options") + group.add_argument("--profile", suggestions=["dev", "prod"]) + + mutex = parser.add_mutually_exclusive_group( + "mode", + required=False, + description="Execution mode", + ) + mutex.add_argument("--dry-run", action="store_true") + mutex.add_argument("--apply", action="store_true") + + parser.add_tldr_examples( + [ + ("target-1 --region us-east", "Deploy target-1 to us-east."), + ("target-2 --dry-run", "Preview target-2 without executing."), + ] + ) + + parser.enable_execution_options( + frozenset( + { + ExecutionOption.SUMMARY, + ExecutionOption.RETRY, + ExecutionOption.CONFIRM, + } + ) + ) + return parser + + +def build_parser_with_tldr_examples() -> CommandArgumentParser: + parser = build_parser() + parser.add_tldr_examples( + [ + ("target-3 --profile dev", "Deploy target-3 using dev profile."), + ] + ) + return parser + + +def build_parser_with_groups() -> CommandArgumentParser: + parser = build_parser() + group = parser.add_argument_group("output", description="Output options") + group.add_argument("--json", action="store_true") + return parser + + +def build_parser_with_execution_options() -> CommandArgumentParser: + parser = build_parser() + parser.enable_execution_options( + frozenset( + { + ExecutionOption.SUMMARY, + ExecutionOption.RETRY, + ExecutionOption.CONFIRM, + } + ) + ) + return parser + + +def test_clone_with_overrides_preserves_core_metadata(): + original = build_parser() + new_options = OptionsManager() + + cloned = original.clone_with_overrides( + command_key="X", + command_description="Execute", + help_text="Execute something else.", + help_epilog="Different epilog.", + aliases=["execute"], + program="target", + options_manager=new_options, + ) + + assert cloned is not original + assert cloned.command_key == "X" + assert cloned.command_description == "Execute" + assert cloned.help_text == "Execute something else." + assert cloned.help_epilog == "Different epilog." + assert cloned.aliases == ["execute"] + assert cloned.program == "target" + assert cloned.options_manager is new_options + + +def test_clone_with_overrides_keeps_execution_options_enabled_without_double_registration(): + original = build_parser() + cloned = original.clone_with_overrides() + + summary = cloned.get_argument("summary") + retries = cloned.get_argument("retries") + retry_delay = cloned.get_argument("retry_delay") + retry_backoff = cloned.get_argument("retry_backoff") + force_confirm = cloned.get_argument("force_confirm") + skip_confirm = cloned.get_argument("skip_confirm") + + assert summary is not None + assert retries is not None + assert retry_delay is not None + assert retry_backoff is not None + assert force_confirm is not None + assert skip_confirm is not None + + # Re-enabling on the clone should be idempotent, not duplicate flags/dests. + cloned.enable_execution_options( + frozenset( + { + ExecutionOption.SUMMARY, + ExecutionOption.RETRY, + ExecutionOption.CONFIRM, + } + ) + ) + + assert len([arg for arg in cloned._arguments if arg.dest == "summary"]) == 1 + assert len([arg for arg in cloned._arguments if arg.dest == "retries"]) == 1 + assert len([arg for arg in cloned._arguments if arg.dest == "retry_delay"]) == 1 + assert len([arg for arg in cloned._arguments if arg.dest == "retry_backoff"]) == 1 + assert len([arg for arg in cloned._arguments if arg.dest == "force_confirm"]) == 1 + assert len([arg for arg in cloned._arguments if arg.dest == "skip_confirm"]) == 1 + + +def test_clone_with_overrides_preserves_groups_and_mutex_groups(): + original = build_parser() + cloned = original.clone_with_overrides() + + assert "auth" in cloned._argument_groups + assert "mode" in cloned._mutex_groups + + assert cloned._arg_group_by_dest["profile"] == "auth" + assert cloned._mutex_group_by_dest["dry_run"] == "mode" + assert cloned._mutex_group_by_dest["apply"] == "mode" + + assert cloned.get_argument("profile") is not None + assert cloned.get_argument("dry_run") is not None + assert cloned.get_argument("apply") is not None + + +def test_clone_with_overrides_preserves_tldr_examples_and_help_flags(): + original = build_parser() + cloned = original.clone_with_overrides() + + assert cloned.help_text == original.help_text + assert cloned.help_epilog == original.help_epilog + assert cloned.get_argument("help") is not None + assert cloned.get_argument("tldr") is not None + assert cloned._tldr_examples == original._tldr_examples + assert cloned._tldr_examples is not original._tldr_examples + + +def test_clone_with_overrides_does_not_share_argument_registries_with_original(): + original = build_parser() + cloned = original.clone_with_overrides() + + assert cloned._arguments is not original._arguments + assert cloned._positional is not original._positional + assert cloned._keyword is not original._keyword + assert cloned._keyword_list is not original._keyword_list + assert cloned._flag_map is not original._flag_map + assert cloned._dest_set is not original._dest_set + assert cloned._execution_dests is not original._execution_dests + + cloned.add_argument("--new-flag", default="x") + + assert cloned.get_argument("new_flag") is not None + assert original.get_argument("new_flag") is None + + +def test_clone_with_overrides_does_not_share_group_registries_with_original(): + original = build_parser() + cloned = original.clone_with_overrides() + + assert cloned._argument_groups is not original._argument_groups + assert cloned._mutex_groups is not original._mutex_groups + assert cloned._arg_group_by_dest is not original._arg_group_by_dest + assert cloned._mutex_group_by_dest is not original._mutex_group_by_dest + + cloned_group = cloned.add_argument_group("output", description="Output options") + cloned_group.add_argument("--json", action="store_true") + + assert "output" in cloned._argument_groups + assert "output" not in original._argument_groups + assert cloned.get_argument("json") is not None + assert original.get_argument("json") is None + + +def test_clone_with_overrides_reuses_no_mutable_group_objects(): + original = build_parser() + cloned = original.clone_with_overrides() + + # These should ideally be distinct objects too, not just distinct dicts. + assert cloned._argument_groups["auth"] is not original._argument_groups["auth"] + assert cloned._mutex_groups["mode"] is not original._mutex_groups["mode"] + + +def test_clone_with_overrides_reuses_no_mutable_argument_objects(): + original = build_parser() + cloned = original.clone_with_overrides() + + # Strict contract: cloned parser should not share Argument instances either. + assert cloned.get_argument("region") is not original.get_argument("region") + assert cloned.get_argument("target") is not original.get_argument("target") + assert cloned.get_argument("profile") is not original.get_argument("profile") + + +def test_clone_with_overrides_uses_new_options_manager(): + original = build_parser() + new_options = OptionsManager() + + cloned = original.clone_with_overrides(options_manager=new_options) + + assert cloned.options_manager is new_options + assert original.options_manager is not new_options + + +def test_clone_with_overrides_has_single_help_and_single_tldr_argument(): + parser = build_parser_with_tldr_examples() + + cloned = parser.clone_with_overrides() + + assert len([arg for arg in cloned._arguments if arg.dest == "help"]) == 1 + assert len([arg for arg in cloned._arguments if arg.dest == "tldr"]) == 1 + assert cloned.get_argument("help") is not None + assert cloned.get_argument("tldr") is not None + + +def test_clone_with_overrides_copies_tldr_examples(): + parser = build_parser_with_tldr_examples() + + cloned = parser.clone_with_overrides() + + assert cloned._tldr_examples == parser._tldr_examples + assert cloned._tldr_examples is not parser._tldr_examples + assert all(c is not o for c, o in zip(cloned._tldr_examples, parser._tldr_examples)) + + +def test_clone_with_overrides_copies_explicit_tldr_examples(): + parser = build_parser() + examples = [TLDRExample("foo", "bar")] + + cloned = parser.clone_with_overrides(tldr_examples=examples) + + assert cloned._tldr_examples == examples + assert cloned._tldr_examples is not examples + + +def test_clone_with_overrides_does_not_share_aliases_list(): + parser = build_parser() + cloned = parser.clone_with_overrides() + + assert cloned.aliases == parser.aliases + assert cloned.aliases is not parser.aliases + + cloned.aliases.append("new-alias") + assert "new-alias" not in parser.aliases + + +def test_clone_with_overrides_rebuilds_group_membership_without_duplicates(): + parser = build_parser_with_groups() + cloned = parser.clone_with_overrides() + + assert cloned._argument_groups["auth"].dests == {"profile"} + assert set(cloned._mutex_groups["mode"].dests) == {"dry_run", "apply"} + assert len(cloned._mutex_groups["mode"].dests) == 2 + + +def test_clone_with_overrides_does_not_share_group_objects(): + parser = build_parser_with_groups() + cloned = parser.clone_with_overrides() + + assert cloned._argument_groups is not parser._argument_groups + assert cloned._mutex_groups is not parser._mutex_groups + assert cloned._argument_groups["auth"] is not parser._argument_groups["auth"] + assert cloned._mutex_groups["mode"] is not parser._mutex_groups["mode"] + + +def test_clone_with_overrides_does_not_share_argument_objects(): + parser = build_parser() + cloned = parser.clone_with_overrides() + + for original_arg in parser._arguments: + cloned_arg = cloned.get_argument(original_arg.dest) + console.print(original_arg) + console.print(cloned_arg) + assert cloned_arg is not None + assert cloned_arg is not original_arg + assert cloned_arg == original_arg + + +def test_clone_with_overrides_internal_registries_point_to_cloned_arguments(): + parser = build_parser() + cloned = parser.clone_with_overrides() + + for arg in cloned._arguments: + for flag in arg.flags: + assert cloned._flag_map[flag] is arg + if not arg.positional: + assert cloned._keyword[flag] is arg + + if arg.positional: + assert cloned._positional[arg.dest] is arg + else: + assert arg in cloned._keyword_list + + +def test_clone_with_overrides_preserves_execution_option_state_without_duplication(): + parser = build_parser_with_execution_options() + cloned = parser.clone_with_overrides() + + assert cloned._summary_enabled is True + assert cloned._retries_enabled is True + assert cloned._confirm_enabled is True + assert cloned._execution_dests == parser._execution_dests + assert cloned._execution_dests is not parser._execution_dests + + cloned.enable_execution_options( + frozenset( + { + ExecutionOption.SUMMARY, + ExecutionOption.RETRY, + ExecutionOption.CONFIRM, + } + ) + ) + + assert len([arg for arg in cloned._arguments if arg.dest == "summary"]) == 1 + assert len([arg for arg in cloned._arguments if arg.dest == "retries"]) == 1 + assert len([arg for arg in cloned._arguments if arg.dest == "retry_delay"]) == 1 + assert len([arg for arg in cloned._arguments if arg.dest == "retry_backoff"]) == 1 + assert len([arg for arg in cloned._arguments if arg.dest == "force_confirm"]) == 1 + assert len([arg for arg in cloned._arguments if arg.dest == "skip_confirm"]) == 1 + + +def test_clone_with_overrides_preserves_runner_and_help_mode_flags(): + parser = build_parser() + parser.is_runner_mode = True + parser.mark_as_help_command() + + cloned = parser.clone_with_overrides() + + assert cloned.is_runner_mode is True + assert cloned._is_help_command is True + + +def test_clone_with_overrides_mutating_clone_does_not_mutate_original(): + parser = build_parser() + cloned = parser.clone_with_overrides() + + cloned.add_argument("--new-flag", default="x") + + assert cloned.get_argument("new_flag") is not None + assert parser.get_argument("new_flag") is None diff --git a/tests/test_parsers/test_command_argument_parser_extra.py b/tests/test_parsers/test_command_argument_parser_extra.py new file mode 100644 index 0000000..09f3daa --- /dev/null +++ b/tests/test_parsers/test_command_argument_parser_extra.py @@ -0,0 +1,459 @@ +from __future__ import annotations + +from io import StringIO +from pathlib import Path + +import pytest +from rich.console import Console + +from falyx.action.action import Action +from falyx.exceptions import CommandArgumentError, InvalidValueError +from falyx.execution_option import ExecutionOption +from falyx.parser.command_argument_parser import CommandArgumentParser +from falyx.signals import HelpSignal + + +@pytest.fixture +def parser() -> CommandArgumentParser: + return CommandArgumentParser( + command_key="D", + command_description="Deploy service", + help_text="Deploy a service.", + help_epilog="Deployment epilog.", + aliases=["deploy"], + program="flx", + ) + + +def capture_console(parser: CommandArgumentParser) -> StringIO: + stream = StringIO() + parser.console = Console( + file=stream, + force_terminal=False, + color_system=None, + width=120, + ) + return stream + + +def test_add_argument_rejects_suggestions_with_non_string_members( + parser: CommandArgumentParser, +) -> None: + with pytest.raises( + CommandArgumentError, match="suggestions must be a list of strings" + ): + parser.add_argument("--region", suggestions=["dev", 1]) + + +@pytest.mark.asyncio +async def test_parse_accepts_multi_value_choice_list_when_all_values_are_valid( + parser: CommandArgumentParser, +) -> None: + parser.add_argument( + "--ports", + type=int, + nargs="+", + choices=["80", 443], + default=[], + ) + + result = await parser.parse_args(["--ports", "80", "443"]) + + assert result["ports"] == [80, 443] + + +@pytest.mark.asyncio +async def test_positional_action_wraps_resolver_failure( + parser: CommandArgumentParser, +) -> None: + async def fail_resolver(value: str) -> str: + raise RuntimeError(f"cannot resolve {value}") + + parser.add_argument( + "target", + action="action", + resolver=Action("Resolve target", fail_resolver), + lazy_resolver=False, + ) + + with pytest.raises( + CommandArgumentError, match=r"\[target\] action failed: cannot resolve web" + ): + await parser.parse_args(["web"]) + + +@pytest.mark.asyncio +async def test_dash_prefixed_numeric_token_can_be_a_positional_value( + parser: CommandArgumentParser, +) -> None: + parser.add_argument("delta", type=int) + + result = await parser.parse_args(["-3"]) + + assert result["delta"] == -3 + + +@pytest.mark.asyncio +async def test_store_option_without_value_raises_type_specific_prompt( + parser: CommandArgumentParser, +) -> None: + parser.add_argument("--count", type=int, help="Number of instances.") + + with pytest.raises(CommandArgumentError, match="enter a int value for 'count'"): + await parser.parse_args(["--count"]) + + +@pytest.mark.asyncio +async def test_append_option_without_value_raises_type_specific_prompt( + parser: CommandArgumentParser, +) -> None: + parser.add_argument("--tag", action="append", help="Deployment tag.") + + with pytest.raises(CommandArgumentError, match="enter a str value for 'tag'"): + await parser.parse_args(["--tag"]) + + +@pytest.mark.asyncio +async def test_tldr_flag_on_help_command_is_parsed_as_a_normal_value( + parser: CommandArgumentParser, +) -> None: + parser.mark_as_help_command() + parser.add_tldr_example("D --region us-east", "Deploy to us-east") + + result = await parser.parse_args(["--tldr"]) + + assert result["tldr"] is True + + +@pytest.mark.asyncio +async def test_tldr_flag_renders_examples_and_raises_help_signal( + parser: CommandArgumentParser, +) -> None: + stream = capture_console(parser) + parser.add_tldr_example("--region us-east", "Deploy to us-east") + + with pytest.raises(HelpSignal): + await parser.parse_args(["--tldr"]) + + output = stream.getvalue() + assert "usage:" in output + assert "examples:" in output + assert "Deploy to us-east" in output + assert "--region us-east" in output + + +@pytest.mark.asyncio +async def test_required_mutex_group_requires_one_member( + parser: CommandArgumentParser, +) -> None: + mode = parser.add_mutually_exclusive_group("mode", required=True) + mode.add_argument("--dry-run", action="store_true") + mode.add_argument("--apply", action="store_true") + + with pytest.raises( + CommandArgumentError, match="one of the following is required for group 'mode'" + ): + await parser.parse_args([]) + + +@pytest.mark.asyncio +async def test_mutex_group_rejects_multiple_present_members( + parser: CommandArgumentParser, +) -> None: + mode = parser.add_mutually_exclusive_group("mode") + mode.add_argument("--dry-run", action="store_true") + mode.add_argument("--apply", action="store_true") + + with pytest.raises( + CommandArgumentError, + match="cannot be used together: (dry_run, apply|apply, dry_run)", + ): + await parser.parse_args(["--dry-run", "--apply"]) + + +@pytest.mark.parametrize( + ("argument_kwargs", "argv"), + [ + ({"flags": ("--enabled",), "action": "store_true"}, ["--enabled", "--other"]), + ({"flags": ("--disabled",), "action": "store_false"}, ["--disabled", "--other"]), + ( + {"flags": ("--feature",), "action": "store_bool_optional"}, + ["--no-feature", "--other"], + ), + ({"flags": ("-v", "--verbose"), "action": "count"}, ["-v", "--other"]), + ({"flags": ("--tag",), "action": "append"}, ["--tag", "beta", "--other"]), + ( + {"flags": ("--item",), "action": "extend", "nargs": "+"}, + ["--item", "a", "--other"], + ), + ({"flags": ("--name",)}, ["--name", "web", "--other"]), + ], +) +@pytest.mark.asyncio +async def test_mutex_presence_detection_handles_all_supported_action_shapes( + argument_kwargs: dict, + argv: list[str], +) -> None: + parser = CommandArgumentParser(command_key="D") + only_one = parser.add_mutually_exclusive_group("only-one") + kwargs = argument_kwargs.copy() + flags = kwargs.pop("flags") + only_one.add_argument(*flags, **kwargs) + only_one.add_argument("--other", action="store_true") + + with pytest.raises(CommandArgumentError, match="cannot be used together"): + await parser.parse_args(argv) + + +@pytest.mark.asyncio +async def test_parse_args_split_separates_execution_options_from_command_inputs( + parser: CommandArgumentParser, +) -> None: + parser.add_argument("service") + parser.add_argument("--region", default="us-east") + parser.enable_execution_options( + frozenset( + { + ExecutionOption.SUMMARY, + ExecutionOption.RETRY, + ExecutionOption.CONFIRM, + } + ) + ) + + args, kwargs, execution_args = await parser.parse_args_split( + [ + "api", + "--region", + "us-west", + "--summary", + "--retries", + "3", + "--retry-delay", + "0.5", + "--retry-backoff", + "2.0", + "--skip-confirm", + ] + ) + + assert args == ("api",) + assert kwargs == {"region": "us-west"} + assert execution_args == { + "summary": True, + "retries": 3, + "retry_delay": 0.5, + "retry_backoff": 2.0, + "force_confirm": False, + "skip_confirm": True, + } + + +@pytest.mark.asyncio +async def test_lazy_action_required_argument_is_deferred_during_validation( + parser: CommandArgumentParser, +) -> None: + calls: list[str] = [] + + async def resolve(value: str) -> str: + calls.append(value) + return value.upper() + + parser.add_argument( + "--target", + action="action", + resolver=Action("Resolve target", resolve), + required=True, + ) + + result = await parser.parse_args(["--target", "web"], from_validate=True) + + assert result["target"] is None + assert calls == [] + + +@pytest.mark.asyncio +async def test_lazy_action_required_argument_still_errors_when_no_tokens_are_present( + parser: CommandArgumentParser, +) -> None: + async def resolve(value: str) -> str: + return value.upper() + + parser.add_argument( + "--target", + action="action", + resolver=Action("Resolve target", resolve), + required=True, + ) + + with pytest.raises(CommandArgumentError, match="missing required argument 'target'"): + await parser.parse_args([], from_validate=True) + + +@pytest.mark.asyncio +async def test_default_list_with_wrong_fixed_nargs_arity_is_invalid( + parser: CommandArgumentParser, +) -> None: + parser.add_argument("--pair", nargs=2, default=["only-one"]) + + with pytest.raises(InvalidValueError) as exc_info: + await parser.parse_args([]) + + assert exc_info.value.dest == "pair" + assert "expected 2" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_required_plus_nargs_option_requires_at_least_one_value( + parser: CommandArgumentParser, +) -> None: + parser.add_argument("--item", nargs="+", required=True) + + with pytest.raises( + CommandArgumentError, match="argument 'item' requires at least one value" + ): + await parser.parse_args([]) + + +@pytest.mark.asyncio +async def test_suggest_next_filters_mutex_siblings_after_one_member_is_consumed( + parser: CommandArgumentParser, +) -> None: + mode = parser.add_mutually_exclusive_group("mode") + mode.add_argument("--dry-run", action="store_true") + mode.add_argument("--apply", action="store_true") + parser.add_argument("--region", choices=["us-east", "us-west"]) + + await parser.parse_args(["--dry-run"]) + + suggestions = parser.suggest_next(["--dry-run"], cursor_at_end_of_token=True) + + assert "--apply" not in suggestions + assert "--region" in suggestions + + +@pytest.mark.asyncio +async def test_optional_choice_argument_can_be_omitted( + parser: CommandArgumentParser, +) -> None: + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--region", choices=["us-east", "us-west"]) + + result = await parser.parse_args(["--dry-run"]) + + assert result["dry_run"] is True + assert result["region"] is None + + +@pytest.mark.asyncio +async def test_suggest_next_returns_no_values_after_invalid_choice_is_committed( + parser: CommandArgumentParser, +) -> None: + parser.add_argument("--region", choices=["dev", "prod"]) + + with pytest.raises(InvalidValueError): + await parser.parse_args(["--region", "qa"]) + + assert parser.suggest_next(["--region", "qa"], cursor_at_end_of_token=True) == [] + + +@pytest.mark.asyncio +async def test_suggest_next_suggests_value_for_keyword_when_stub_starts_with_dash( + parser: CommandArgumentParser, +) -> None: + parser.add_argument("--profile", suggestions=["-prod", "-stage", "dev"]) + + with pytest.raises(CommandArgumentError): + await parser.parse_args(["--profile"], from_validate=True) + + assert parser.suggest_next(["--profile", "-"], cursor_at_end_of_token=False) == [ + "-prod", + "-stage", + ] + + +@pytest.mark.asyncio +async def test_suggest_next_returns_empty_for_missing_path_base( + parser: CommandArgumentParser, + tmp_path: Path, +) -> None: + missing = tmp_path / "missing-dir" / "config.toml" + parser.add_argument("--config", type=Path) + + await parser.parse_args(["--config", str(missing)], from_validate=True) + + assert ( + parser.suggest_next(["--config", str(missing)], cursor_at_end_of_token=False) + == [] + ) + + +def test_get_options_text_repeats_fixed_width_positional_nargs( + parser: CommandArgumentParser, +) -> None: + parser.add_argument("coords", nargs=2) + + assert "coords coords" in parser.get_options_text() + + +def test_get_usage_uses_program_only_when_parser_is_in_runner_mode( + parser: CommandArgumentParser, +) -> None: + parser.is_runner_mode = True + parser.program = "deploy-tool" + + usage = parser.get_usage() + + assert "deploy-tool" in usage + assert "[bold]D[/bold]" not in usage + assert "[bold]deploy[/bold]" not in usage + + +def test_render_help_includes_grouped_keywords_bool_optional_pair_and_epilog( + parser: CommandArgumentParser, +) -> None: + stream = capture_console(parser) + parser.add_argument("environment", help="Target environment.") + deploy = parser.add_argument_group("deploy", "Deployment options.") + deploy.add_argument("--region", help="Target region.") + mode = parser.add_mutually_exclusive_group("mode") + mode.add_argument("--dry-run", action="store_true", help="Preview only.") + parser.add_argument("--cache", action="store_bool_optional", help="Use cache.") + + parser.render_help() + + output = stream.getvalue() + assert "usage:" in output + assert "Deploy a service." in output + assert "positional:" in output + assert "environment" in output + assert "deploy:" in output + assert "Deployment options." in output + assert "--cache, --no-cache" in output + assert "Deployment epilog." in output + + +def test_render_tldr_without_examples_prints_empty_state_message( + parser: CommandArgumentParser, +) -> None: + stream = capture_console(parser) + + parser.render_tldr() + + assert "No TLDR examples available for D" in stream.getvalue() + + +def test_render_tldr_with_examples_prints_usage_help_and_example_panel( + parser: CommandArgumentParser, +) -> None: + stream = capture_console(parser) + parser.add_tldr_example("--region us-east", "Deploy east") + + parser.render_tldr() + + output = stream.getvalue() + assert "usage:" in output + assert "Deploy a service." in output + assert "examples:" in output + assert "Deploy east" in output + assert "--region us-east" in output diff --git a/tests/test_parsers/test_execution_option_registration.py b/tests/test_parsers/test_execution_option_registration.py index 3ef0f42..f6a3367 100644 --- a/tests/test_parsers/test_execution_option_registration.py +++ b/tests/test_parsers/test_execution_option_registration.py @@ -34,10 +34,6 @@ def test_enable_execution_options_registers_retry_flags(): def test_enable_execution_options_invalid_double_registration_raises(): parser = CommandArgumentParser() parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY})) - with pytest.raises( - CommandArgumentError, match="destination 'summary' is already defined" - ): - parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY})) with pytest.raises( CommandArgumentError, @@ -68,9 +64,10 @@ def test_register_execution_dest_rejects_duplicates(): parser.add_argument("--summary", action="store_true") with pytest.raises( - CommandArgumentError, match="destination 'summary' is already defined" + CommandArgumentError, + match="destination 'summary' is already registered as an execution argument", ): - parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY})) + parser._register_execution_dest("summary") @pytest.mark.asyncio diff --git a/tests/test_parsers/test_resolve_args.py b/tests/test_parsers/test_resolve_args.py index faf0e1d..528c1f2 100644 --- a/tests/test_parsers/test_resolve_args.py +++ b/tests/test_parsers/test_resolve_args.py @@ -76,9 +76,10 @@ async def test_resolve_args_raises_on_conflicting_execution_option(): ) with pytest.raises( - CommandArgumentError, match="destination 'summary' is already defined" + CommandArgumentError, + match="destination 'summary' is already registered as an execution argument", ): - command.arg_parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY})) + command.arg_parser._register_execution_dest("summary") @pytest.mark.asyncio diff --git a/tests/test_runner/test_command_runner.py b/tests/test_runner/test_command_runner.py index 3d339c4..3d8199f 100644 --- a/tests/test_runner/test_command_runner.py +++ b/tests/test_runner/test_command_runner.py @@ -1,10 +1,12 @@ import asyncio +import logging import sys import pytest from rich.console import Console from rich.text import Text +from falyx import Falyx from falyx.action import Action from falyx.command import Command from falyx.command_runner import CommandRunner @@ -18,6 +20,7 @@ from falyx.exceptions import ( ) from falyx.hook_manager import HookManager, HookType from falyx.options_manager import OptionsManager +from falyx.parser import CommandArgumentParser from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal @@ -133,15 +136,15 @@ async def test_command_runner_initialization( assert runner.command == command_with_parser assert runner.program == "test_program" assert runner.command.arg_parser.program == "test_program" - assert isinstance(runner.options, OptionsManager) + assert isinstance(runner.options_manager, OptionsManager) assert isinstance(runner.runner_hooks, HookManager) assert runner.console == falyx_console - assert runner.command.options_manager == runner.options - assert runner.command.arg_parser.options_manager == runner.options - assert runner.command.options_manager == runner.options - assert runner.executor.options == runner.options + assert runner.command.options_manager == runner.options_manager + assert runner.command.arg_parser.options_manager == runner.options_manager + assert runner.command.options_manager == runner.options_manager + assert runner.executor.options_manager == runner.options_manager assert runner.executor.hooks == runner.runner_hooks - assert runner.options.get("summary", namespace_name="execution") is None + assert runner.options_manager.get("summary", namespace_name="execution") is None runner_no_parser = CommandRunner(command_with_no_parser) assert runner_no_parser.command == command_with_no_parser @@ -161,12 +164,12 @@ async def test_command_runner_initialization( def test_command_runner_initialization_with_custom_options(command_with_parser): custom_options = OptionsManager([("default", {"summary": True})]) - runner = CommandRunner(command_with_parser, options=custom_options) - assert runner.options == custom_options - assert runner.options.get("summary", namespace_name="default") is True - assert runner.command.options_manager == runner.options - assert runner.command.arg_parser.options_manager == runner.options - assert runner.command.options_manager == runner.options + runner = CommandRunner(command_with_parser, options_manager=custom_options) + assert runner.options_manager == custom_options + assert runner.options_manager.get("summary", namespace_name="default") is True + assert runner.command.options_manager == runner.options_manager + assert runner.command.arg_parser.options_manager == runner.options_manager + assert runner.command.options_manager == runner.options_manager def test_command_runner_initialization_with_custom_console(command_with_parser): @@ -190,11 +193,11 @@ def test_command_runner_initialization_with_all_bad_components(command_with_pars custom_hooks = "Not a HookManager" with pytest.raises( - NotAFalyxError, match="options must be an instance of OptionsManager" + NotAFalyxError, match="options_manager must be an instance of OptionsManager" ): CommandRunner( command_with_parser, - options=custom_options, + options_manager=custom_options, ) with pytest.raises( @@ -247,9 +250,10 @@ async def test_command_runner_run_with_failing_action(command_with_failing_actio @pytest.mark.asyncio async def test_command_runner_debug_statement(command_with_parser, caplog): - caplog.set_level("DEBUG") + logging.getLogger("falyx").setLevel(logging.DEBUG) runner = CommandRunner(command_with_parser) await runner.run("--foo 42") + print(command_with_parser.get_option("verbose", namespace_name="root")) assert ( "Executing command 'Test Command' with args=(), kwargs={'foo': 42}" in caplog.text ) @@ -539,3 +543,126 @@ async def test_command_runner_run_error(command_with_parser): await runner.run(["--foo", "42"], raise_on_error=False, wrap_errors=False) await runner.run(["--foo", "42"], raise_on_error=False, wrap_errors=True) await runner.run(["--foo", "42"], raise_on_error=True, wrap_errors=False) + + +def test_command_runner_from_command_reuses_custom_options_manager_and_seeds_missing_namespaces(): + flx = Falyx(program="source") + original = flx.add_command( + key="D", + description="Deploy", + action=lambda: "ok", + ) + + custom_options = OptionsManager([("default", {"summary": True})]) + + runner = CommandRunner.from_command( + original, + options_manager=custom_options, + ) + + assert runner.command is not original + assert runner.options_manager is custom_options + assert runner.command.options_manager is custom_options + + assert runner.options_manager.get("summary", namespace_name="default") is True + + assert runner.options_manager.get_namespace("root") == {} + assert runner.options_manager.get_namespace("execution") == {} + + assert original.options_manager is flx.options_manager + assert original.options_manager is not custom_options + + +@pytest.mark.asyncio +async def test_command_runner_root_options_affect_cloned_command_without_mutating_original( + monkeypatch, +): + flx = Falyx(program="source") + original = flx.add_command( + key="D", + description="Deploy", + action=Action("deploy-action", lambda: "ok"), + confirm=True, + ) + + original.options_manager.set("never_prompt", False, "root") + original.options_manager.set("verbose", False, "root") + + runner_options = OptionsManager() + runner_options.from_mapping({}, "root") + runner_options.from_mapping({}, "execution") + runner_options.set("never_prompt", True, "root") + runner_options.set("verbose", True, "root") + + runner = CommandRunner.from_command( + original, + options_manager=runner_options, + ) + + calls = {"preview": 0, "confirm": 0} + + async def fake_preview(self): + calls["preview"] += 1 + + async def fake_confirm(*args, **kwargs): + calls["confirm"] += 1 + return True + + monkeypatch.setattr(Command, "preview", fake_preview) + monkeypatch.setattr("falyx.command.confirm_async", fake_confirm) + + result = await runner.run([]) + + assert result == "ok" + + assert calls["preview"] == 0 + assert calls["confirm"] == 0 + + assert runner.command.get_option("never_prompt", namespace_name="root") is True + assert runner.command.get_option("verbose", namespace_name="root") is True + + assert runner.command.action.get_option("verbose", namespace_name="root") is True + assert runner.command.action.never_prompt is True + + assert original.get_option("never_prompt", namespace_name="root") is False + assert original.get_option("verbose", namespace_name="root") is False + assert original.options_manager is flx.options_manager + assert original.options_manager is not runner.options_manager + + +@pytest.mark.asyncio +async def test_command_runner_from_command_with_custom_options_preserves_parity_and_isolation(): + falyx = Falyx("Custom Options Parity Test") + + def add(x: int, y: int) -> int: + return x + y + + command = falyx.add_command( + key="A", + description="Add", + action=add, + ) + + custom_options = OptionsManager([("default", {"summary": True})]) + + runner = CommandRunner.from_command( + command, + options_manager=custom_options, + ) + + falyx_result = await falyx.execute_command("A 2 3") + runner_result = await runner.run(["2", "3"]) + + assert falyx_result == 5 + assert runner_result == 5 + assert falyx_result == runner_result + + assert runner.options_manager is custom_options + assert runner.command.options_manager is custom_options + assert runner.options_manager.get("summary", namespace_name="default") is True + assert runner.options_manager.get_namespace("root") == {} + assert runner.options_manager.get_namespace("execution") == {} + + assert runner.command is not command + assert command.options_manager is falyx.options_manager + assert command.options_manager is not runner.options_manager diff --git a/tests/test_selection.py b/tests/test_selection.py new file mode 100644 index 0000000..314cb70 --- /dev/null +++ b/tests/test_selection.py @@ -0,0 +1,489 @@ +from __future__ import annotations + +from typing import Any + +import pytest +from rich import box +from rich.table import Table + +import falyx.selection as selection_module +from falyx.selection import ( + SelectionOption, + SelectionOptionMap, + get_selection_from_dict_menu, + prompt_for_index, + prompt_for_selection, + render_selection_dict_table, + render_selection_grid, + render_selection_indexed_table, + render_table_base, + select_key_from_dict, + select_value_from_dict, + select_value_from_list, +) +from falyx.themes import OneColors + + +class CaptureConsole: + def __init__(self) -> None: + self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] + + def print(self, *args: Any, **kwargs: Any) -> None: + self.printed.append((args, kwargs)) + + +class FakePromptSession: + def __init__(self, *responses: str) -> None: + self.responses = list(responses) + self.calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] + self.message: Any = "original-message" + self.validator: Any = "original-validator" + self.placeholder: Any = "original-placeholder" + + async def prompt_async(self, *args: Any, **kwargs: Any) -> str: + self.calls.append((args, kwargs)) + self.message = kwargs.get("message") + self.validator = kwargs.get("validator") + self.placeholder = kwargs.get("placeholder") + if not self.responses: + raise AssertionError("No fake prompt response configured") + return self.responses.pop(0) + + +@pytest.fixture +def sample_options() -> dict[str, SelectionOption]: + return { + "dev": SelectionOption("Development", "dev-value", style="green"), + "prod": SelectionOption("Production", "prod-value", style="red"), + "stage": SelectionOption("Staging", "stage-value", style="yellow"), + } + + +def test_selection_option_rejects_non_string_description() -> None: + with pytest.raises(TypeError, match="description must be a string"): + SelectionOption(123, "value") # type: ignore[arg-type] + + +def test_selection_option_render_escapes_key_and_applies_style() -> None: + option = SelectionOption("Deploy [prod]", "prod", style="red") + + rendered = option.render("a[b]") + + assert "a" in rendered + assert "Deploy [prod]" in rendered + assert f"[{OneColors.WHITE}]" in rendered + assert "[red]" in rendered + + +def test_selection_option_copy_returns_independent_equivalent_option() -> None: + option = SelectionOption("Development", {"env": "dev"}, style="green") + + copied = option.copy() + + assert copied == option + assert copied is not option + + +def test_selection_option_map_initializes_from_options_case_insensitively( + sample_options: dict[str, SelectionOption], +) -> None: + mapping = SelectionOptionMap({"dev": sample_options["dev"]}) + + assert mapping["DEV"] is sample_options["dev"] + assert mapping["dev"] is sample_options["dev"] + assert list(mapping.items()) == [("DEV", sample_options["dev"])] + + +def test_selection_option_map_rejects_non_selection_option_values() -> None: + mapping = SelectionOptionMap() + + with pytest.raises(TypeError, match="must be a SelectionOption"): + mapping["bad"] = "not an option" # type: ignore[assignment] + + with pytest.raises(TypeError, match="must be a SelectionOption"): + mapping.update({"bad": "not an option"}) + + with pytest.raises(TypeError, match="must be a SelectionOption"): + mapping.update(bad="not an option") + + +def test_selection_option_map_update_accepts_kwargs_and_copy_is_deep_for_options( + sample_options: dict[str, SelectionOption], +) -> None: + mapping = SelectionOptionMap() + mapping.update(dev=sample_options["dev"]) + mapping.update({"prod": sample_options["prod"]}) + + copied = mapping.copy() + + assert copied.allow_reserved is mapping.allow_reserved + assert copied["DEV"] == mapping["dev"] + assert copied["DEV"] is not mapping["dev"] + assert copied["PROD"].description == "Production" + + +def test_selection_option_map_reserved_key_protection_and_bypass( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(SelectionOptionMap, "RESERVED_KEYS", {"QUIT"}) + mapping = SelectionOptionMap() + reserved_option = SelectionOption("Quit", None) + + with pytest.raises(ValueError, match="reserved"): + mapping["quit"] = reserved_option + + mapping._add_reserved("quit", reserved_option) + assert mapping["QUIT"] is reserved_option + + with pytest.raises(ValueError, match="Cannot delete reserved option"): + del mapping["quit"] + + assert list(mapping.items(include_reserved=False)) == [] + + +def test_selection_option_map_allows_reserved_key_when_configured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(SelectionOptionMap, "RESERVED_KEYS", {"QUIT"}) + mapping = SelectionOptionMap(allow_reserved=True) + option = SelectionOption("Quit", None) + + mapping["quit"] = option + assert mapping["QUIT"] is option + + del mapping["quit"] + assert "QUIT" not in mapping + + +def test_render_table_base_uses_explicit_column_names_and_styles() -> None: + table = render_table_base( + "Environments", + caption="Choose carefully", + column_names=["Name", "Description"], + box_style=box.ROUNDED, + show_lines=True, + show_header=True, + show_footer=True, + style="blue", + header_style="bold", + footer_style="dim", + title_style="green", + caption_style="yellow", + highlight=False, + ) + + assert isinstance(table, Table) + assert table.title == "Environments" + assert table.caption == "Choose carefully" + assert len(table.columns) == 2 + assert table.columns[0].header == "Name" + assert table.columns[1].header == "Description" + assert table.show_header is True + assert table.show_footer is True + assert table.highlight is False + + +def test_render_table_base_creates_blank_columns_when_no_names_are_given() -> None: + table = render_table_base("Choices", columns=3) + + assert len(table.columns) == 3 + + +def test_render_selection_grid_chunks_rows() -> None: + table = render_selection_grid("Choices", ["alpha", "beta", "gamma"], columns=2) + + assert table.title == "Choices" + assert len(table.columns) == 2 + assert len(table.rows) == 2 + + +def test_render_selection_indexed_table_uses_default_and_custom_formatters() -> None: + default_table = render_selection_indexed_table( + "Indexed", ["alpha", "beta", "gamma"], columns=2 + ) + formatted_table = render_selection_indexed_table( + "Formatted", + ["alpha", "beta"], + columns=2, + formatter=lambda index, value: f"{index}:{value.upper()}", + ) + + assert len(default_table.rows) == 2 + assert len(formatted_table.rows) == 1 + + +def test_render_selection_dict_table_renders_option_rows( + sample_options: dict[str, SelectionOption], +) -> None: + table = render_selection_dict_table( + "Environments", + sample_options, + columns=2, + caption="Pick one", + highlight=True, + ) + + assert table.title == "Environments" + assert table.caption == "Pick one" + assert len(table.columns) == 2 + assert len(table.rows) == 2 + assert table.highlight is True + + +@pytest.mark.asyncio +async def test_prompt_for_index_returns_single_index_and_restores_prompt_state( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_console = CaptureConsole() + monkeypatch.setattr(selection_module, "console", fake_console) + session = FakePromptSession(" 2 ") + table = render_table_base("Choices") + + result = await prompt_for_index( + 5, + table, + default_selection="1", + prompt_session=session, # type: ignore[arg-type] + prompt_message="[bold]Pick >[/] ", + show_table=True, + number_selections=1, + cancel_key="9", + ) + + assert result == 2 + assert len(fake_console.printed) == 1 + _, kwargs = session.calls[0] + assert kwargs["default"] == "1" + assert kwargs["placeholder"] == "Enter selection" + assert kwargs["validator"].__class__.__name__ == "MultiIndexValidator" + assert session.message == "original-message" + assert session.validator == "original-validator" + assert session.placeholder == "original-placeholder" + + +@pytest.mark.asyncio +async def test_prompt_for_index_returns_cancel_key_as_int() -> None: + session = FakePromptSession(" 7 ") + table = render_table_base("Choices") + + result = await prompt_for_index( + 7, + table, + prompt_session=session, # type: ignore[arg-type] + show_table=False, + cancel_key="7", + ) + + assert result == 7 + + +@pytest.mark.asyncio +async def test_prompt_for_index_returns_multiple_indexes_with_custom_separator() -> None: + session = FakePromptSession("0 ; 2 ; 4") + table = render_table_base("Choices") + + result = await prompt_for_index( + 5, + table, + prompt_session=session, # type: ignore[arg-type] + show_table=False, + number_selections=3, + separator=";", + allow_duplicates=True, + ) + + assert result == [0, 2, 4] + _, kwargs = session.calls[0] + assert kwargs["placeholder"] == "Enter 3 selections separated by ';'" + assert kwargs["validator"].__class__.__name__ == "MultiIndexValidator" + + +@pytest.mark.asyncio +async def test_prompt_for_selection_returns_single_key_and_prints_table( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_console = CaptureConsole() + monkeypatch.setattr(selection_module, "console", fake_console) + session = FakePromptSession(" DEV ") + table = render_table_base("Choices") + + result = await prompt_for_selection( + ["DEV", "PROD"], + table, + default_selection="PROD", + prompt_session=session, # type: ignore[arg-type] + prompt_message="Select > ", + show_table=True, + number_selections=1, + cancel_key="Q", + ) + + assert result == "DEV" + assert len(fake_console.printed) == 1 + _, kwargs = session.calls[0] + assert kwargs["default"] == "PROD" + assert kwargs["placeholder"] == "Enter selection" + assert kwargs["validator"].__class__.__name__ == "MultiKeyValidator" + + +@pytest.mark.asyncio +async def test_prompt_for_selection_returns_cancel_key() -> None: + session = FakePromptSession(" q ") + table = render_table_base("Choices") + + result = await prompt_for_selection( + ["dev", "prod", "q"], + table, + prompt_session=session, # type: ignore[arg-type] + show_table=False, + cancel_key="q", + ) + + assert result == "q" + + +@pytest.mark.asyncio +async def test_prompt_for_selection_returns_multiple_keys_with_custom_separator() -> None: + session = FakePromptSession("dev | prod | stage") + table = render_table_base("Choices") + + result = await prompt_for_selection( + ["dev", "prod", "stage"], + table, + prompt_session=session, # type: ignore[arg-type] + show_table=False, + number_selections="*", + separator="|", + allow_duplicates=True, + ) + + assert result == ["dev", "prod", "stage"] + _, kwargs = session.calls[0] + assert kwargs["placeholder"] == "Enter selections separated by '|'" + + +@pytest.mark.asyncio +async def test_select_value_from_list_returns_single_and_multiple_values( + monkeypatch: pytest.MonkeyPatch, +) -> None: + prompt_calls: list[dict[str, Any]] = [] + + async def fake_prompt_for_index(max_index: int, table: Table, **kwargs: Any) -> Any: + prompt_calls.append({"max_index": max_index, "table": table, **kwargs}) + return [0, 2] if kwargs["number_selections"] != 1 else 1 + + monkeypatch.setattr(selection_module, "prompt_for_index", fake_prompt_for_index) + + single = await select_value_from_list( + "Languages", + ["python", "rust", "go"], + default_selection="1", + number_selections=1, + ) + multiple = await select_value_from_list( + "Languages", + ["python", "rust", "go"], + default_selection="0,2", + number_selections=2, + ) + + assert single == "rust" + assert multiple == ["python", "go"] + assert prompt_calls[0]["max_index"] == 2 + assert prompt_calls[0]["default_selection"] == "1" + assert prompt_calls[1]["number_selections"] == 2 + + +@pytest.mark.asyncio +async def test_select_key_from_dict_delegates_to_prompt_for_selection( + monkeypatch: pytest.MonkeyPatch, + sample_options: dict[str, SelectionOption], +) -> None: + fake_console = CaptureConsole() + monkeypatch.setattr(selection_module, "console", fake_console) + calls: list[dict[str, Any]] = [] + + async def fake_prompt_for_selection(keys: Any, table: Table, **kwargs: Any) -> str: + calls.append({"keys": list(keys), "table": table, **kwargs}) + return "prod" + + monkeypatch.setattr( + selection_module, "prompt_for_selection", fake_prompt_for_selection + ) + table = render_table_base("Environments") + + result = await select_key_from_dict( + sample_options, + table, + default_selection="dev", + number_selections=1, + cancel_key="q", + ) + + assert result == "prod" + assert len(fake_console.printed) == 1 + assert calls[0]["keys"] == ["dev", "prod", "stage"] + assert calls[0]["default_selection"] == "dev" + assert calls[0]["cancel_key"] == "q" + + +@pytest.mark.asyncio +async def test_select_value_from_dict_returns_single_and_multiple_values( + monkeypatch: pytest.MonkeyPatch, + sample_options: dict[str, SelectionOption], +) -> None: + fake_console = CaptureConsole() + monkeypatch.setattr(selection_module, "console", fake_console) + responses: list[Any] = ["prod", ["dev", "stage"]] + + async def fake_prompt_for_selection(keys: Any, table: Table, **kwargs: Any) -> Any: + return responses.pop(0) + + monkeypatch.setattr( + selection_module, "prompt_for_selection", fake_prompt_for_selection + ) + table = render_table_base("Environments") + + single = await select_value_from_dict(sample_options, table) + multiple = await select_value_from_dict( + sample_options, + table, + number_selections=2, + separator=",", + ) + + assert single == "prod-value" + assert multiple == ["dev-value", "stage-value"] + assert len(fake_console.printed) == 2 + + +@pytest.mark.asyncio +async def test_get_selection_from_dict_menu_builds_table_and_returns_selected_value( + monkeypatch: pytest.MonkeyPatch, + sample_options: dict[str, SelectionOption], +) -> None: + calls: list[dict[str, Any]] = [] + + async def fake_select_value_from_dict( + *, selections: dict[str, SelectionOption], table: Table, **kwargs: Any + ) -> str: + calls.append({"selections": selections, "table": table, **kwargs}) + return "prod-value" + + monkeypatch.setattr( + selection_module, "select_value_from_dict", fake_select_value_from_dict + ) + + result = await get_selection_from_dict_menu( + "Environments", + sample_options, + default_selection="prod", + number_selections=1, + cancel_key="q", + ) + + assert result == "prod-value" + assert calls[0]["selections"] is sample_options + assert calls[0]["table"].title == "Environments" + assert calls[0]["default_selection"] == "prod" + assert calls[0]["cancel_key"] == "q"