Add falyx.console for single rich.console.Console instance, Add ConfirmAction, SaveFileAction, Add lazy evaluation for ArgumentAction.ACTION
This commit is contained in:
108
examples/confirm_example.py
Normal file
108
examples/confirm_example.py
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from falyx import Falyx
|
||||||
|
from falyx.action import Action, ActionFactory, ChainedAction, ConfirmAction
|
||||||
|
from falyx.parser import CommandArgumentParser
|
||||||
|
|
||||||
|
|
||||||
|
class Dog(BaseModel):
|
||||||
|
name: str
|
||||||
|
age: int
|
||||||
|
breed: str
|
||||||
|
|
||||||
|
|
||||||
|
async def get_dogs(*dog_names: str) -> list[Dog]:
|
||||||
|
"""Simulate fetching dog data."""
|
||||||
|
await asyncio.sleep(0.1) # Simulate network delay
|
||||||
|
dogs = [
|
||||||
|
Dog(name="Buddy", age=3, breed="Golden Retriever"),
|
||||||
|
Dog(name="Max", age=5, breed="Beagle"),
|
||||||
|
Dog(name="Bella", age=2, breed="Bulldog"),
|
||||||
|
Dog(name="Charlie", age=4, breed="Poodle"),
|
||||||
|
Dog(name="Lucy", age=1, breed="Labrador"),
|
||||||
|
Dog(name="Spot", age=6, breed="German Shepherd"),
|
||||||
|
]
|
||||||
|
dogs = [
|
||||||
|
dog for dog in dogs if dog.name.upper() in (name.upper() for name in dog_names)
|
||||||
|
]
|
||||||
|
if not dogs:
|
||||||
|
raise ValueError(f"No dogs found with the names: {', '.join(dog_names)}")
|
||||||
|
return dogs
|
||||||
|
|
||||||
|
|
||||||
|
async def build_json_updates(dogs: list[Dog]) -> list[dict[str, Any]]:
|
||||||
|
"""Build JSON updates for the dogs."""
|
||||||
|
print(f"Building JSON updates for {','.join(dog.name for dog in dogs)}")
|
||||||
|
return [dog.model_dump(mode="json") for dog in dogs]
|
||||||
|
|
||||||
|
|
||||||
|
def after_action(dogs) -> None:
|
||||||
|
if not dogs:
|
||||||
|
print("No dogs processed.")
|
||||||
|
return
|
||||||
|
for result in dogs:
|
||||||
|
print(Dog(**result))
|
||||||
|
|
||||||
|
|
||||||
|
async def build_chain(dogs: list[Dog]) -> ChainedAction:
|
||||||
|
return ChainedAction(
|
||||||
|
name="test_chain",
|
||||||
|
actions=[
|
||||||
|
Action(
|
||||||
|
name="build_json_updates",
|
||||||
|
action=build_json_updates,
|
||||||
|
kwargs={"dogs": dogs},
|
||||||
|
),
|
||||||
|
ConfirmAction(
|
||||||
|
name="test_confirm",
|
||||||
|
message="Do you want to process the dogs?",
|
||||||
|
confirm_type="yes_no",
|
||||||
|
return_last_result=True,
|
||||||
|
inject_into="dogs",
|
||||||
|
),
|
||||||
|
Action(
|
||||||
|
name="after_action",
|
||||||
|
action=after_action,
|
||||||
|
inject_into="dogs",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
auto_inject=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
factory = ActionFactory(
|
||||||
|
name="Dog Post Factory",
|
||||||
|
factory=build_chain,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dog_config(parser: CommandArgumentParser) -> None:
|
||||||
|
parser.add_argument(
|
||||||
|
"dogs",
|
||||||
|
nargs="+",
|
||||||
|
action="action",
|
||||||
|
resolver=Action("Get Dogs", get_dogs),
|
||||||
|
lazy_resolver=False,
|
||||||
|
help="List of dogs to process.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
flx = Falyx("Dog Post Example")
|
||||||
|
|
||||||
|
flx.add_command(
|
||||||
|
key="D",
|
||||||
|
description="Post Dog Data",
|
||||||
|
action=factory,
|
||||||
|
aliases=["post_dogs"],
|
||||||
|
argument_config=dog_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
await flx.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
@ -12,6 +12,7 @@ from .falyx import Falyx
|
|||||||
|
|
||||||
logger = logging.getLogger("falyx")
|
logger = logging.getLogger("falyx")
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Falyx",
|
"Falyx",
|
||||||
"ExecutionRegistry",
|
"ExecutionRegistry",
|
||||||
|
@ -10,6 +10,7 @@ from .action_factory import ActionFactory
|
|||||||
from .action_group import ActionGroup
|
from .action_group import ActionGroup
|
||||||
from .base_action import BaseAction
|
from .base_action import BaseAction
|
||||||
from .chained_action import ChainedAction
|
from .chained_action import ChainedAction
|
||||||
|
from .confirm_action import ConfirmAction
|
||||||
from .fallback_action import FallbackAction
|
from .fallback_action import FallbackAction
|
||||||
from .http_action import HTTPAction
|
from .http_action import HTTPAction
|
||||||
from .io_action import BaseIOAction
|
from .io_action import BaseIOAction
|
||||||
@ -19,6 +20,7 @@ from .menu_action import MenuAction
|
|||||||
from .process_action import ProcessAction
|
from .process_action import ProcessAction
|
||||||
from .process_pool_action import ProcessPoolAction
|
from .process_pool_action import ProcessPoolAction
|
||||||
from .prompt_menu_action import PromptMenuAction
|
from .prompt_menu_action import PromptMenuAction
|
||||||
|
from .save_file_action import SaveFileAction
|
||||||
from .select_file_action import SelectFileAction
|
from .select_file_action import SelectFileAction
|
||||||
from .selection_action import SelectionAction
|
from .selection_action import SelectionAction
|
||||||
from .shell_action import ShellAction
|
from .shell_action import ShellAction
|
||||||
@ -45,4 +47,6 @@ __all__ = [
|
|||||||
"PromptMenuAction",
|
"PromptMenuAction",
|
||||||
"ProcessPoolAction",
|
"ProcessPoolAction",
|
||||||
"LoadFileAction",
|
"LoadFileAction",
|
||||||
|
"SaveFileAction",
|
||||||
|
"ConfirmAction",
|
||||||
]
|
]
|
||||||
|
@ -14,6 +14,7 @@ from falyx.exceptions import EmptyGroupError
|
|||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
from falyx.hook_manager import Hook, HookManager, HookType
|
from falyx.hook_manager import Hook, HookManager, HookType
|
||||||
from falyx.logger import logger
|
from falyx.logger import logger
|
||||||
|
from falyx.options_manager import OptionsManager
|
||||||
from falyx.parser.utils import same_argument_definitions
|
from falyx.parser.utils import same_argument_definitions
|
||||||
from falyx.themes.colors import OneColors
|
from falyx.themes.colors import OneColors
|
||||||
|
|
||||||
@ -96,6 +97,11 @@ class ActionGroup(BaseAction, ActionListMixin):
|
|||||||
for action in actions:
|
for action in actions:
|
||||||
self.add_action(action)
|
self.add_action(action)
|
||||||
|
|
||||||
|
def set_options_manager(self, options_manager: OptionsManager) -> None:
|
||||||
|
super().set_options_manager(options_manager)
|
||||||
|
for action in self.actions:
|
||||||
|
action.set_options_manager(options_manager)
|
||||||
|
|
||||||
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
|
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
|
||||||
arg_defs = same_argument_definitions(self.actions)
|
arg_defs = same_argument_definitions(self.actions)
|
||||||
if arg_defs:
|
if arg_defs:
|
||||||
|
@ -36,6 +36,7 @@ from typing import Any, Callable
|
|||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
|
from falyx.console import console
|
||||||
from falyx.context import SharedContext
|
from falyx.context import SharedContext
|
||||||
from falyx.debug import register_debug_hooks
|
from falyx.debug import register_debug_hooks
|
||||||
from falyx.hook_manager import Hook, HookManager, HookType
|
from falyx.hook_manager import Hook, HookManager, HookType
|
||||||
@ -73,7 +74,7 @@ class BaseAction(ABC):
|
|||||||
self.inject_into: str = inject_into
|
self.inject_into: str = inject_into
|
||||||
self._never_prompt: bool = never_prompt
|
self._never_prompt: bool = never_prompt
|
||||||
self._skip_in_chain: bool = False
|
self._skip_in_chain: bool = False
|
||||||
self.console = Console(color_system="truecolor")
|
self.console: Console = console
|
||||||
self.options_manager: OptionsManager | None = None
|
self.options_manager: OptionsManager | None = None
|
||||||
|
|
||||||
if logging_hooks:
|
if logging_hooks:
|
||||||
|
@ -16,6 +16,7 @@ from falyx.exceptions import EmptyChainError
|
|||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
from falyx.hook_manager import Hook, HookManager, HookType
|
from falyx.hook_manager import Hook, HookManager, HookType
|
||||||
from falyx.logger import logger
|
from falyx.logger import logger
|
||||||
|
from falyx.options_manager import OptionsManager
|
||||||
from falyx.themes import OneColors
|
from falyx.themes import OneColors
|
||||||
|
|
||||||
|
|
||||||
@ -92,6 +93,11 @@ class ChainedAction(BaseAction, ActionListMixin):
|
|||||||
for action in actions:
|
for action in actions:
|
||||||
self.add_action(action)
|
self.add_action(action)
|
||||||
|
|
||||||
|
def set_options_manager(self, options_manager: OptionsManager) -> None:
|
||||||
|
super().set_options_manager(options_manager)
|
||||||
|
for action in self.actions:
|
||||||
|
action.set_options_manager(options_manager)
|
||||||
|
|
||||||
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
|
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
|
||||||
if self.actions:
|
if self.actions:
|
||||||
return self.actions[0].get_infer_target()
|
return self.actions[0].get_infer_target()
|
||||||
@ -197,7 +203,7 @@ class ChainedAction(BaseAction, ActionListMixin):
|
|||||||
|
|
||||||
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
|
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
|
||||||
"""Register a hook for all actions and sub-actions."""
|
"""Register a hook for all actions and sub-actions."""
|
||||||
self.hooks.register(hook_type, hook)
|
super().register_hooks_recursively(hook_type, hook)
|
||||||
for action in self.actions:
|
for action in self.actions:
|
||||||
action.register_hooks_recursively(hook_type, hook)
|
action.register_hooks_recursively(hook_type, hook)
|
||||||
|
|
||||||
|
217
falyx/action/confirm_action.py
Normal file
217
falyx/action/confirm_action.py
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from prompt_toolkit import PromptSession
|
||||||
|
from rich.tree import Tree
|
||||||
|
|
||||||
|
from falyx.action.base_action import BaseAction
|
||||||
|
from falyx.context import ExecutionContext
|
||||||
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
|
from falyx.hook_manager import HookType
|
||||||
|
from falyx.logger import logger
|
||||||
|
from falyx.prompt_utils import confirm_async, should_prompt_user
|
||||||
|
from falyx.signals import CancelSignal
|
||||||
|
from falyx.themes import OneColors
|
||||||
|
from falyx.validators import word_validator, words_validator
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmType(Enum):
|
||||||
|
"""Enum for different confirmation types."""
|
||||||
|
|
||||||
|
YES_NO = "yes_no"
|
||||||
|
YES_CANCEL = "yes_cancel"
|
||||||
|
YES_NO_CANCEL = "yes_no_cancel"
|
||||||
|
TYPE_WORD = "type_word"
|
||||||
|
OK_CANCEL = "ok_cancel"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def choices(cls) -> list[ConfirmType]:
|
||||||
|
"""Return a list of all hook type choices."""
|
||||||
|
return list(cls)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Return the string representation of the confirm type."""
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmAction(BaseAction):
|
||||||
|
"""
|
||||||
|
Action to confirm an operation with the user.
|
||||||
|
|
||||||
|
There are several ways to confirm an action, such as using a simple
|
||||||
|
yes/no prompt. You can also use a confirmation type that requires the user
|
||||||
|
to type a specific word or phrase to confirm the action, or use an OK/Cancel
|
||||||
|
dialog.
|
||||||
|
|
||||||
|
This action can be used to ensure that the user explicitly agrees to proceed
|
||||||
|
with an operation.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name (str): Name of the action.
|
||||||
|
message (str): The confirmation message to display.
|
||||||
|
confirm_type (ConfirmType | str): The type of confirmation to use.
|
||||||
|
Options include YES_NO, YES_CANCEL, YES_NO_CANCEL, TYPE_WORD, and OK_CANCEL.
|
||||||
|
prompt_session (PromptSession | None): The session to use for input.
|
||||||
|
confirm (bool): Whether to prompt the user for confirmation.
|
||||||
|
word (str): The word to type for TYPE_WORD confirmation.
|
||||||
|
return_last_result (bool): Whether to return the last result of the action.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
message: str = "Continue",
|
||||||
|
confirm_type: ConfirmType | str = ConfirmType.YES_NO,
|
||||||
|
prompt_session: PromptSession | None = None,
|
||||||
|
confirm: bool = True,
|
||||||
|
word: str = "CONFIRM",
|
||||||
|
return_last_result: bool = False,
|
||||||
|
inject_last_result: bool = True,
|
||||||
|
inject_into: str = "last_result",
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the ConfirmAction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message (str): The confirmation message to display.
|
||||||
|
confirm_type (ConfirmType): The type of confirmation to use.
|
||||||
|
Options include YES_NO, YES_CANCEL, YES_NO_CANCEL, TYPE_WORD, and OK_CANCEL.
|
||||||
|
prompt_session (PromptSession | None): The session to use for input.
|
||||||
|
confirm (bool): Whether to prompt the user for confirmation.
|
||||||
|
word (str): The word to type for TYPE_WORD confirmation.
|
||||||
|
return_last_result (bool): Whether to return the last result of the action.
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
inject_last_result=inject_last_result,
|
||||||
|
inject_into=inject_into,
|
||||||
|
)
|
||||||
|
self.message = message
|
||||||
|
self.confirm_type = self._coerce_confirm_type(confirm_type)
|
||||||
|
self.prompt_session = prompt_session or PromptSession()
|
||||||
|
self.confirm = confirm
|
||||||
|
self.word = word
|
||||||
|
self.return_last_result = return_last_result
|
||||||
|
|
||||||
|
def _coerce_confirm_type(self, confirm_type: ConfirmType | str) -> ConfirmType:
|
||||||
|
"""Coerce the confirm_type to a ConfirmType enum."""
|
||||||
|
if isinstance(confirm_type, ConfirmType):
|
||||||
|
return confirm_type
|
||||||
|
elif isinstance(confirm_type, str):
|
||||||
|
return ConfirmType(confirm_type)
|
||||||
|
return ConfirmType(confirm_type)
|
||||||
|
|
||||||
|
async def _confirm(self) -> bool:
|
||||||
|
"""Confirm the action with the user."""
|
||||||
|
match self.confirm_type:
|
||||||
|
case ConfirmType.YES_NO:
|
||||||
|
return await confirm_async(
|
||||||
|
self.message,
|
||||||
|
prefix="❓ ",
|
||||||
|
suffix=" [Y/n] > ",
|
||||||
|
session=self.prompt_session,
|
||||||
|
)
|
||||||
|
case ConfirmType.YES_NO_CANCEL:
|
||||||
|
answer = await self.prompt_session.prompt_async(
|
||||||
|
f"❓ {self.message} ([Y]es, [N]o, or [C]ancel to abort): ",
|
||||||
|
validator=words_validator(["Y", "N", "C"]),
|
||||||
|
)
|
||||||
|
if answer.upper() == "C":
|
||||||
|
raise CancelSignal(f"Action '{self.name}' was cancelled by the user.")
|
||||||
|
return answer.upper() == "Y"
|
||||||
|
case ConfirmType.TYPE_WORD:
|
||||||
|
answer = await self.prompt_session.prompt_async(
|
||||||
|
f"❓ {self.message} (type '{self.word}' to confirm or N/n): ",
|
||||||
|
validator=word_validator(self.word),
|
||||||
|
)
|
||||||
|
return answer.upper().strip() != "N"
|
||||||
|
case ConfirmType.YES_CANCEL:
|
||||||
|
answer = await confirm_async(
|
||||||
|
self.message,
|
||||||
|
prefix="❓ ",
|
||||||
|
suffix=" [Y/n] > ",
|
||||||
|
session=self.prompt_session,
|
||||||
|
)
|
||||||
|
if not answer:
|
||||||
|
raise CancelSignal(f"Action '{self.name}' was cancelled by the user.")
|
||||||
|
return answer
|
||||||
|
case ConfirmType.OK_CANCEL:
|
||||||
|
answer = await self.prompt_session.prompt_async(
|
||||||
|
f"❓ {self.message} ([O]k to continue, [C]ancel to abort): ",
|
||||||
|
validator=words_validator(["O", "C"]),
|
||||||
|
)
|
||||||
|
if answer.upper() == "C":
|
||||||
|
raise CancelSignal(f"Action '{self.name}' was cancelled by the user.")
|
||||||
|
return answer.upper() == "O"
|
||||||
|
case _:
|
||||||
|
raise ValueError(f"Unknown confirm_type: {self.confirm_type}")
|
||||||
|
|
||||||
|
def get_infer_target(self) -> tuple[None, None]:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
async def _run(self, *args, **kwargs) -> Any:
|
||||||
|
combined_kwargs = self._maybe_inject_last_result(kwargs)
|
||||||
|
context = ExecutionContext(
|
||||||
|
name=self.name, args=args, kwargs=combined_kwargs, action=self
|
||||||
|
)
|
||||||
|
context.start_timer()
|
||||||
|
try:
|
||||||
|
await self.hooks.trigger(HookType.BEFORE, context)
|
||||||
|
if (
|
||||||
|
not self.confirm
|
||||||
|
or self.options_manager
|
||||||
|
and not should_prompt_user(
|
||||||
|
confirm=self.confirm, options=self.options_manager
|
||||||
|
)
|
||||||
|
):
|
||||||
|
logger.debug(
|
||||||
|
"Skipping confirmation for action '%s' as 'confirm' is False or options manager indicates no prompt.",
|
||||||
|
self.name,
|
||||||
|
)
|
||||||
|
if self.return_last_result:
|
||||||
|
result = combined_kwargs[self.inject_into]
|
||||||
|
else:
|
||||||
|
result = True
|
||||||
|
else:
|
||||||
|
answer = await self._confirm()
|
||||||
|
if self.return_last_result and answer:
|
||||||
|
result = combined_kwargs[self.inject_into]
|
||||||
|
else:
|
||||||
|
result = answer
|
||||||
|
logger.debug("Action '%s' confirmed with result: %s", self.name, result)
|
||||||
|
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||||
|
return result
|
||||||
|
except Exception as error:
|
||||||
|
context.exception = error
|
||||||
|
await self.hooks.trigger(HookType.ON_ERROR, context)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
context.stop_timer()
|
||||||
|
await self.hooks.trigger(HookType.AFTER, context)
|
||||||
|
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
||||||
|
er.record(context)
|
||||||
|
|
||||||
|
async def preview(self, parent: Tree | None = None) -> None:
|
||||||
|
tree = (
|
||||||
|
Tree(
|
||||||
|
f"[{OneColors.CYAN_b}]ConfirmAction[/]: {self.name}",
|
||||||
|
guide_style=OneColors.BLUE_b,
|
||||||
|
)
|
||||||
|
if not parent
|
||||||
|
else parent.add(f"[{OneColors.CYAN_b}]ConfirmAction[/]: {self.name}")
|
||||||
|
)
|
||||||
|
tree.add(f"[bold]Message:[/] {self.message}")
|
||||||
|
tree.add(f"[bold]Type:[/] {self.confirm_type.value}")
|
||||||
|
tree.add(f"[bold]Prompt Required:[/] {'Yes' if self.confirm else 'No'}")
|
||||||
|
if self.confirm_type == ConfirmType.TYPE_WORD:
|
||||||
|
tree.add(f"[bold]Confirmation Word:[/] {self.word}")
|
||||||
|
if parent is None:
|
||||||
|
self.console.print(tree)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"ConfirmAction(name={self.name}, message={self.message}, "
|
||||||
|
f"confirm_type={self.confirm_type})"
|
||||||
|
)
|
@ -80,9 +80,14 @@ class LoadFileAction(BaseAction):
|
|||||||
def get_infer_target(self) -> tuple[None, None]:
|
def get_infer_target(self) -> tuple[None, None]:
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
def load_file(self) -> Any:
|
async def load_file(self) -> Any:
|
||||||
|
"""Load and parse the file based on its type."""
|
||||||
if self.file_path is None:
|
if self.file_path is None:
|
||||||
raise ValueError("file_path must be set before loading a file")
|
raise ValueError("file_path must be set before loading a file")
|
||||||
|
elif not self.file_path.exists():
|
||||||
|
raise FileNotFoundError(f"File not found: {self.file_path}")
|
||||||
|
elif not self.file_path.is_file():
|
||||||
|
raise ValueError(f"Path is not a regular file: {self.file_path}")
|
||||||
value: Any = None
|
value: Any = None
|
||||||
try:
|
try:
|
||||||
if self.file_type == FileType.TEXT:
|
if self.file_type == FileType.TEXT:
|
||||||
@ -125,14 +130,7 @@ class LoadFileAction(BaseAction):
|
|||||||
elif self.inject_last_result and self.last_result:
|
elif self.inject_last_result and self.last_result:
|
||||||
self.file_path = self.last_result
|
self.file_path = self.last_result
|
||||||
|
|
||||||
if self.file_path is None:
|
result = await self.load_file()
|
||||||
raise ValueError("file_path must be set before loading a file")
|
|
||||||
elif not self.file_path.exists():
|
|
||||||
raise FileNotFoundError(f"File not found: {self.file_path}")
|
|
||||||
elif not self.file_path.is_file():
|
|
||||||
raise ValueError(f"Path is not a regular file: {self.file_path}")
|
|
||||||
|
|
||||||
result = self.load_file()
|
|
||||||
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||||
return result
|
return result
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
@ -3,7 +3,6 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from prompt_toolkit import PromptSession
|
from prompt_toolkit import PromptSession
|
||||||
from rich.console import Console
|
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
@ -33,7 +32,6 @@ class MenuAction(BaseAction):
|
|||||||
default_selection: str = "",
|
default_selection: str = "",
|
||||||
inject_last_result: bool = False,
|
inject_last_result: bool = False,
|
||||||
inject_into: str = "last_result",
|
inject_into: str = "last_result",
|
||||||
console: Console | None = None,
|
|
||||||
prompt_session: PromptSession | None = None,
|
prompt_session: PromptSession | None = None,
|
||||||
never_prompt: bool = False,
|
never_prompt: bool = False,
|
||||||
include_reserved: bool = True,
|
include_reserved: bool = True,
|
||||||
@ -51,10 +49,6 @@ class MenuAction(BaseAction):
|
|||||||
self.columns = columns
|
self.columns = columns
|
||||||
self.prompt_message = prompt_message
|
self.prompt_message = prompt_message
|
||||||
self.default_selection = default_selection
|
self.default_selection = default_selection
|
||||||
if isinstance(console, Console):
|
|
||||||
self.console = console
|
|
||||||
elif console:
|
|
||||||
raise ValueError("`console` must be an instance of `rich.console.Console`")
|
|
||||||
self.prompt_session = prompt_session or PromptSession()
|
self.prompt_session = prompt_session or PromptSession()
|
||||||
self.include_reserved = include_reserved
|
self.include_reserved = include_reserved
|
||||||
self.show_table = show_table
|
self.show_table = show_table
|
||||||
@ -115,7 +109,6 @@ class MenuAction(BaseAction):
|
|||||||
self.menu_options.keys(),
|
self.menu_options.keys(),
|
||||||
table,
|
table,
|
||||||
default_selection=self.default_selection,
|
default_selection=self.default_selection,
|
||||||
console=self.console,
|
|
||||||
prompt_session=self.prompt_session,
|
prompt_session=self.prompt_session,
|
||||||
prompt_message=self.prompt_message,
|
prompt_message=self.prompt_message,
|
||||||
show_table=self.show_table,
|
show_table=self.show_table,
|
||||||
|
@ -4,7 +4,6 @@ from typing import Any
|
|||||||
|
|
||||||
from prompt_toolkit import PromptSession
|
from prompt_toolkit import PromptSession
|
||||||
from prompt_toolkit.formatted_text import FormattedText, merge_formatted_text
|
from prompt_toolkit.formatted_text import FormattedText, merge_formatted_text
|
||||||
from rich.console import Console
|
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
from falyx.action.base_action import BaseAction
|
from falyx.action.base_action import BaseAction
|
||||||
@ -29,7 +28,6 @@ class PromptMenuAction(BaseAction):
|
|||||||
default_selection: str = "",
|
default_selection: str = "",
|
||||||
inject_last_result: bool = False,
|
inject_last_result: bool = False,
|
||||||
inject_into: str = "last_result",
|
inject_into: str = "last_result",
|
||||||
console: Console | None = None,
|
|
||||||
prompt_session: PromptSession | None = None,
|
prompt_session: PromptSession | None = None,
|
||||||
never_prompt: bool = False,
|
never_prompt: bool = False,
|
||||||
include_reserved: bool = True,
|
include_reserved: bool = True,
|
||||||
@ -43,10 +41,6 @@ class PromptMenuAction(BaseAction):
|
|||||||
self.menu_options = menu_options
|
self.menu_options = menu_options
|
||||||
self.prompt_message = prompt_message
|
self.prompt_message = prompt_message
|
||||||
self.default_selection = default_selection
|
self.default_selection = default_selection
|
||||||
if isinstance(console, Console):
|
|
||||||
self.console = console
|
|
||||||
elif console:
|
|
||||||
raise ValueError("`console` must be an instance of `rich.console.Console`")
|
|
||||||
self.prompt_session = prompt_session or PromptSession()
|
self.prompt_session = prompt_session or PromptSession()
|
||||||
self.include_reserved = include_reserved
|
self.include_reserved = include_reserved
|
||||||
|
|
||||||
|
232
falyx/action/save_file_action.py
Normal file
232
falyx/action/save_file_action.py
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
|
"""save_file_action.py"""
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
import toml
|
||||||
|
import yaml
|
||||||
|
from rich.tree import Tree
|
||||||
|
|
||||||
|
from falyx.action.action_types import FileType
|
||||||
|
from falyx.action.base_action import BaseAction
|
||||||
|
from falyx.context import ExecutionContext
|
||||||
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
|
from falyx.hook_manager import HookType
|
||||||
|
from falyx.logger import logger
|
||||||
|
from falyx.themes import OneColors
|
||||||
|
|
||||||
|
|
||||||
|
class SaveFileAction(BaseAction):
|
||||||
|
"""
|
||||||
|
SaveFileAction saves data to a file in the specified format (e.g., TEXT, JSON, YAML).
|
||||||
|
Supports overwrite control and integrates with chaining workflows via inject_last_result.
|
||||||
|
|
||||||
|
Supported types: TEXT, JSON, YAML, TOML, CSV, TSV, XML
|
||||||
|
|
||||||
|
If the file exists and overwrite is False, the action will raise a FileExistsError.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
file_path: str,
|
||||||
|
file_type: FileType | str = FileType.TEXT,
|
||||||
|
mode: Literal["w", "a"] = "w",
|
||||||
|
inject_last_result: bool = True,
|
||||||
|
inject_into: str = "data",
|
||||||
|
overwrite: bool = True,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
SaveFileAction allows saving data to a file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name (str): Name of the action.
|
||||||
|
file_path (str | Path): Path to the file where data will be saved.
|
||||||
|
file_type (FileType | str): Format to write to (e.g. TEXT, JSON, YAML).
|
||||||
|
inject_last_result (bool): Whether to inject result from previous action.
|
||||||
|
inject_into (str): Kwarg name to inject the last result as.
|
||||||
|
overwrite (bool): Whether to overwrite the file if it exists.
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name, inject_last_result=inject_last_result, inject_into=inject_into
|
||||||
|
)
|
||||||
|
self._file_path = self._coerce_file_path(file_path)
|
||||||
|
self._file_type = self._coerce_file_type(file_type)
|
||||||
|
self.overwrite = overwrite
|
||||||
|
self.mode = mode
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_path(self) -> Path | None:
|
||||||
|
"""Get the file path as a Path object."""
|
||||||
|
return self._file_path
|
||||||
|
|
||||||
|
@file_path.setter
|
||||||
|
def file_path(self, value: str | Path):
|
||||||
|
"""Set the file path, converting to Path if necessary."""
|
||||||
|
self._file_path = self._coerce_file_path(value)
|
||||||
|
|
||||||
|
def _coerce_file_path(self, file_path: str | Path | None) -> Path | None:
|
||||||
|
"""Coerce the file path to a Path object."""
|
||||||
|
if isinstance(file_path, Path):
|
||||||
|
return file_path
|
||||||
|
elif isinstance(file_path, str):
|
||||||
|
return Path(file_path)
|
||||||
|
elif file_path is None:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
raise TypeError("file_path must be a string or Path object")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_type(self) -> FileType:
|
||||||
|
"""Get the file type."""
|
||||||
|
return self._file_type
|
||||||
|
|
||||||
|
@file_type.setter
|
||||||
|
def file_type(self, value: FileType | str):
|
||||||
|
"""Set the file type, converting to FileType if necessary."""
|
||||||
|
self._file_type = self._coerce_file_type(value)
|
||||||
|
|
||||||
|
def _coerce_file_type(self, file_type: FileType | str) -> FileType:
|
||||||
|
"""Coerce the file type to a FileType enum."""
|
||||||
|
if isinstance(file_type, FileType):
|
||||||
|
return file_type
|
||||||
|
elif isinstance(file_type, str):
|
||||||
|
return FileType(file_type)
|
||||||
|
else:
|
||||||
|
raise TypeError("file_type must be a FileType enum or string")
|
||||||
|
|
||||||
|
def get_infer_target(self) -> tuple[None, None]:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def _dict_to_xml(self, data: dict, root: ET.Element) -> None:
|
||||||
|
"""Convert a dictionary to XML format."""
|
||||||
|
for key, value in data.items():
|
||||||
|
if isinstance(value, dict):
|
||||||
|
sub_element = ET.SubElement(root, key)
|
||||||
|
self._dict_to_xml(value, sub_element)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for item in value:
|
||||||
|
item_element = ET.SubElement(root, key)
|
||||||
|
if isinstance(item, dict):
|
||||||
|
self._dict_to_xml(item, item_element)
|
||||||
|
else:
|
||||||
|
item_element.text = str(item)
|
||||||
|
else:
|
||||||
|
element = ET.SubElement(root, key)
|
||||||
|
element.text = str(value)
|
||||||
|
|
||||||
|
async def save_file(self, data: Any) -> None:
|
||||||
|
"""Save data to the specified file in the desired format."""
|
||||||
|
if self.file_path is None:
|
||||||
|
raise ValueError("file_path must be set before saving a file")
|
||||||
|
elif self.file_path.exists() and not self.overwrite:
|
||||||
|
raise FileExistsError(f"File already exists: {self.file_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.file_type == FileType.TEXT:
|
||||||
|
self.file_path.write_text(data, encoding="UTF-8")
|
||||||
|
elif self.file_type == FileType.JSON:
|
||||||
|
self.file_path.write_text(json.dumps(data, indent=4), encoding="UTF-8")
|
||||||
|
elif self.file_type == FileType.TOML:
|
||||||
|
self.file_path.write_text(toml.dumps(data), encoding="UTF-8")
|
||||||
|
elif self.file_type == FileType.YAML:
|
||||||
|
self.file_path.write_text(yaml.dump(data), encoding="UTF-8")
|
||||||
|
elif self.file_type == FileType.CSV:
|
||||||
|
if not isinstance(data, list) or not all(
|
||||||
|
isinstance(row, list) for row in data
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"{self.file_type.name} file type requires a list of lists"
|
||||||
|
)
|
||||||
|
with open(
|
||||||
|
self.file_path, mode=self.mode, newline="", encoding="UTF-8"
|
||||||
|
) as csvfile:
|
||||||
|
writer = csv.writer(csvfile)
|
||||||
|
writer.writerows(data)
|
||||||
|
elif self.file_type == FileType.TSV:
|
||||||
|
if not isinstance(data, list) or not all(
|
||||||
|
isinstance(row, list) for row in data
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"{self.file_type.name} file type requires a list of lists"
|
||||||
|
)
|
||||||
|
with open(
|
||||||
|
self.file_path, mode=self.mode, newline="", encoding="UTF-8"
|
||||||
|
) as tsvfile:
|
||||||
|
writer = csv.writer(tsvfile, delimiter="\t")
|
||||||
|
writer.writerows(data)
|
||||||
|
elif self.file_type == FileType.XML:
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("XML file type requires data to be a dictionary")
|
||||||
|
root = ET.Element("root")
|
||||||
|
self._dict_to_xml(data, root)
|
||||||
|
tree = ET.ElementTree(root)
|
||||||
|
tree.write(self.file_path, encoding="UTF-8", xml_declaration=True)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported file type: {self.file_type}")
|
||||||
|
|
||||||
|
except Exception as error:
|
||||||
|
logger.error("Failed to save %s: %s", self.file_path.name, error)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _run(self, *args, **kwargs):
|
||||||
|
combined_kwargs = self._maybe_inject_last_result(kwargs)
|
||||||
|
data = combined_kwargs.get(self.inject_into)
|
||||||
|
|
||||||
|
context = ExecutionContext(
|
||||||
|
name=self.name, args=args, kwargs=combined_kwargs, action=self
|
||||||
|
)
|
||||||
|
context.start_timer()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.hooks.trigger(HookType.BEFORE, context)
|
||||||
|
|
||||||
|
await self.save_file(data)
|
||||||
|
logger.debug("File saved successfully: %s", self.file_path)
|
||||||
|
|
||||||
|
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||||
|
return str(self.file_path)
|
||||||
|
|
||||||
|
except Exception as error:
|
||||||
|
context.exception = error
|
||||||
|
await self.hooks.trigger(HookType.ON_ERROR, context)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
context.stop_timer()
|
||||||
|
await self.hooks.trigger(HookType.AFTER, context)
|
||||||
|
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
||||||
|
er.record(context)
|
||||||
|
|
||||||
|
async def preview(self, parent: Tree | None = None):
|
||||||
|
label = f"[{OneColors.CYAN}]💾 SaveFileAction[/] '{self.name}'"
|
||||||
|
tree = parent.add(label) if parent else Tree(label)
|
||||||
|
|
||||||
|
tree.add(f"[dim]Path:[/] {self.file_path}")
|
||||||
|
tree.add(f"[dim]Type:[/] {self.file_type.name}")
|
||||||
|
tree.add(f"[dim]Overwrite:[/] {self.overwrite}")
|
||||||
|
|
||||||
|
if self.file_path and self.file_path.exists():
|
||||||
|
if self.overwrite:
|
||||||
|
tree.add(f"[{OneColors.LIGHT_YELLOW}]⚠️ File will be overwritten[/]")
|
||||||
|
else:
|
||||||
|
tree.add(
|
||||||
|
f"[{OneColors.DARK_RED}]❌ File exists and overwrite is disabled[/]"
|
||||||
|
)
|
||||||
|
stat = self.file_path.stat()
|
||||||
|
tree.add(f"[dim]Size:[/] {stat.st_size:,} bytes")
|
||||||
|
tree.add(
|
||||||
|
f"[dim]Modified:[/] {datetime.fromtimestamp(stat.st_mtime):%Y-%m-%d %H:%M:%S}"
|
||||||
|
)
|
||||||
|
tree.add(
|
||||||
|
f"[dim]Created:[/] {datetime.fromtimestamp(stat.st_ctime):%Y-%m-%d %H:%M:%S}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not parent:
|
||||||
|
self.console.print(tree)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"SaveFileAction(file_path={self.file_path}, file_type={self.file_type})"
|
@ -11,7 +11,6 @@ from typing import Any
|
|||||||
import toml
|
import toml
|
||||||
import yaml
|
import yaml
|
||||||
from prompt_toolkit import PromptSession
|
from prompt_toolkit import PromptSession
|
||||||
from rich.console import Console
|
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
from falyx.action.action_types import FileType
|
from falyx.action.action_types import FileType
|
||||||
@ -51,7 +50,6 @@ class SelectFileAction(BaseAction):
|
|||||||
style (str): Style for the selection options.
|
style (str): Style for the selection options.
|
||||||
suffix_filter (str | None): Restrict to certain file types.
|
suffix_filter (str | None): Restrict to certain file types.
|
||||||
return_type (FileType): What to return (path, content, parsed).
|
return_type (FileType): What to return (path, content, parsed).
|
||||||
console (Console | None): Console instance for output.
|
|
||||||
prompt_session (PromptSession | None): Prompt session for user input.
|
prompt_session (PromptSession | None): Prompt session for user input.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -69,7 +67,6 @@ class SelectFileAction(BaseAction):
|
|||||||
number_selections: int | str = 1,
|
number_selections: int | str = 1,
|
||||||
separator: str = ",",
|
separator: str = ",",
|
||||||
allow_duplicates: bool = False,
|
allow_duplicates: bool = False,
|
||||||
console: Console | None = None,
|
|
||||||
prompt_session: PromptSession | None = None,
|
prompt_session: PromptSession | None = None,
|
||||||
):
|
):
|
||||||
super().__init__(name)
|
super().__init__(name)
|
||||||
@ -82,10 +79,6 @@ class SelectFileAction(BaseAction):
|
|||||||
self.number_selections = number_selections
|
self.number_selections = number_selections
|
||||||
self.separator = separator
|
self.separator = separator
|
||||||
self.allow_duplicates = allow_duplicates
|
self.allow_duplicates = allow_duplicates
|
||||||
if isinstance(console, Console):
|
|
||||||
self.console = console
|
|
||||||
elif console:
|
|
||||||
raise ValueError("`console` must be an instance of `rich.console.Console`")
|
|
||||||
self.prompt_session = prompt_session or PromptSession()
|
self.prompt_session = prompt_session or PromptSession()
|
||||||
self.return_type = self._coerce_return_type(return_type)
|
self.return_type = self._coerce_return_type(return_type)
|
||||||
|
|
||||||
@ -195,7 +188,6 @@ class SelectFileAction(BaseAction):
|
|||||||
keys = await prompt_for_selection(
|
keys = await prompt_for_selection(
|
||||||
(options | cancel_option).keys(),
|
(options | cancel_option).keys(),
|
||||||
table,
|
table,
|
||||||
console=self.console,
|
|
||||||
prompt_session=self.prompt_session,
|
prompt_session=self.prompt_session,
|
||||||
prompt_message=self.prompt_message,
|
prompt_message=self.prompt_message,
|
||||||
number_selections=self.number_selections,
|
number_selections=self.number_selections,
|
||||||
|
@ -3,7 +3,6 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from prompt_toolkit import PromptSession
|
from prompt_toolkit import PromptSession
|
||||||
from rich.console import Console
|
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
from falyx.action.action_types import SelectionReturnType
|
from falyx.action.action_types import SelectionReturnType
|
||||||
@ -54,7 +53,6 @@ class SelectionAction(BaseAction):
|
|||||||
inject_last_result: bool = False,
|
inject_last_result: bool = False,
|
||||||
inject_into: str = "last_result",
|
inject_into: str = "last_result",
|
||||||
return_type: SelectionReturnType | str = "value",
|
return_type: SelectionReturnType | str = "value",
|
||||||
console: Console | None = None,
|
|
||||||
prompt_session: PromptSession | None = None,
|
prompt_session: PromptSession | None = None,
|
||||||
never_prompt: bool = False,
|
never_prompt: bool = False,
|
||||||
show_table: bool = True,
|
show_table: bool = True,
|
||||||
@ -70,10 +68,6 @@ class SelectionAction(BaseAction):
|
|||||||
self.return_type: SelectionReturnType = self._coerce_return_type(return_type)
|
self.return_type: SelectionReturnType = self._coerce_return_type(return_type)
|
||||||
self.title = title
|
self.title = title
|
||||||
self.columns = columns
|
self.columns = columns
|
||||||
if isinstance(console, Console):
|
|
||||||
self.console = console
|
|
||||||
elif console:
|
|
||||||
raise ValueError("`console` must be an instance of `rich.console.Console`")
|
|
||||||
self.prompt_session = prompt_session or PromptSession()
|
self.prompt_session = prompt_session or PromptSession()
|
||||||
self.default_selection = default_selection
|
self.default_selection = default_selection
|
||||||
self.number_selections = number_selections
|
self.number_selections = number_selections
|
||||||
@ -262,7 +256,6 @@ class SelectionAction(BaseAction):
|
|||||||
len(self.selections),
|
len(self.selections),
|
||||||
table,
|
table,
|
||||||
default_selection=effective_default,
|
default_selection=effective_default,
|
||||||
console=self.console,
|
|
||||||
prompt_session=self.prompt_session,
|
prompt_session=self.prompt_session,
|
||||||
prompt_message=self.prompt_message,
|
prompt_message=self.prompt_message,
|
||||||
show_table=self.show_table,
|
show_table=self.show_table,
|
||||||
@ -306,7 +299,6 @@ class SelectionAction(BaseAction):
|
|||||||
(self.selections | cancel_option).keys(),
|
(self.selections | cancel_option).keys(),
|
||||||
table,
|
table,
|
||||||
default_selection=effective_default,
|
default_selection=effective_default,
|
||||||
console=self.console,
|
|
||||||
prompt_session=self.prompt_session,
|
prompt_session=self.prompt_session,
|
||||||
prompt_message=self.prompt_message,
|
prompt_message=self.prompt_message,
|
||||||
show_table=self.show_table,
|
show_table=self.show_table,
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
"""user_input_action.py"""
|
"""user_input_action.py"""
|
||||||
from prompt_toolkit import PromptSession
|
from prompt_toolkit import PromptSession
|
||||||
from prompt_toolkit.validation import Validator
|
from prompt_toolkit.validation import Validator
|
||||||
from rich.console import Console
|
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
from falyx.action.base_action import BaseAction
|
from falyx.action.base_action import BaseAction
|
||||||
@ -20,7 +19,6 @@ class UserInputAction(BaseAction):
|
|||||||
name (str): Action name.
|
name (str): Action name.
|
||||||
prompt_text (str): Prompt text (can include '{last_result}' for interpolation).
|
prompt_text (str): Prompt text (can include '{last_result}' for interpolation).
|
||||||
validator (Validator, optional): Prompt Toolkit validator.
|
validator (Validator, optional): Prompt Toolkit validator.
|
||||||
console (Console, optional): Rich console for rendering.
|
|
||||||
prompt_session (PromptSession, optional): Reusable prompt session.
|
prompt_session (PromptSession, optional): Reusable prompt session.
|
||||||
inject_last_result (bool): Whether to inject last_result into prompt.
|
inject_last_result (bool): Whether to inject last_result into prompt.
|
||||||
inject_into (str): Key to use for injection (default: 'last_result').
|
inject_into (str): Key to use for injection (default: 'last_result').
|
||||||
@ -33,7 +31,6 @@ class UserInputAction(BaseAction):
|
|||||||
prompt_text: str = "Input > ",
|
prompt_text: str = "Input > ",
|
||||||
default_text: str = "",
|
default_text: str = "",
|
||||||
validator: Validator | None = None,
|
validator: Validator | None = None,
|
||||||
console: Console | None = None,
|
|
||||||
prompt_session: PromptSession | None = None,
|
prompt_session: PromptSession | None = None,
|
||||||
inject_last_result: bool = False,
|
inject_last_result: bool = False,
|
||||||
):
|
):
|
||||||
@ -43,10 +40,6 @@ class UserInputAction(BaseAction):
|
|||||||
)
|
)
|
||||||
self.prompt_text = prompt_text
|
self.prompt_text = prompt_text
|
||||||
self.validator = validator
|
self.validator = validator
|
||||||
if isinstance(console, Console):
|
|
||||||
self.console = console
|
|
||||||
elif console:
|
|
||||||
raise ValueError("`console` must be an instance of `rich.console.Console`")
|
|
||||||
self.prompt_session = prompt_session or PromptSession()
|
self.prompt_session = prompt_session or PromptSession()
|
||||||
self.default_text = default_text
|
self.default_text = default_text
|
||||||
|
|
||||||
|
@ -5,8 +5,8 @@ from typing import Any, Callable
|
|||||||
|
|
||||||
from prompt_toolkit.formatted_text import HTML, merge_formatted_text
|
from prompt_toolkit.formatted_text import HTML, merge_formatted_text
|
||||||
from prompt_toolkit.key_binding import KeyBindings
|
from prompt_toolkit.key_binding import KeyBindings
|
||||||
from rich.console import Console
|
|
||||||
|
|
||||||
|
from falyx.console import console
|
||||||
from falyx.options_manager import OptionsManager
|
from falyx.options_manager import OptionsManager
|
||||||
from falyx.themes import OneColors
|
from falyx.themes import OneColors
|
||||||
from falyx.utils import CaseInsensitiveDict, chunks
|
from falyx.utils import CaseInsensitiveDict, chunks
|
||||||
@ -30,7 +30,7 @@ class BottomBar:
|
|||||||
key_validator: Callable[[str], bool] | None = None,
|
key_validator: Callable[[str], bool] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.columns = columns
|
self.columns = columns
|
||||||
self.console = Console(color_system="truecolor")
|
self.console: Console = console
|
||||||
self._named_items: dict[str, Callable[[], HTML]] = {}
|
self._named_items: dict[str, Callable[[], HTML]] = {}
|
||||||
self._value_getters: dict[str, Callable[[], Any]] = CaseInsensitiveDict()
|
self._value_getters: dict[str, Callable[[], Any]] = CaseInsensitiveDict()
|
||||||
self.toggle_keys: list[str] = []
|
self.toggle_keys: list[str] = []
|
||||||
|
@ -23,11 +23,11 @@ from typing import Any, Awaitable, Callable
|
|||||||
|
|
||||||
from prompt_toolkit.formatted_text import FormattedText
|
from prompt_toolkit.formatted_text import FormattedText
|
||||||
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
|
||||||
from rich.console import Console
|
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
from falyx.action.action import Action
|
from falyx.action.action import Action
|
||||||
from falyx.action.base_action import BaseAction
|
from falyx.action.base_action import BaseAction
|
||||||
|
from falyx.console import console
|
||||||
from falyx.context import ExecutionContext
|
from falyx.context import ExecutionContext
|
||||||
from falyx.debug import register_debug_hooks
|
from falyx.debug import register_debug_hooks
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
@ -44,8 +44,6 @@ from falyx.signals import CancelSignal
|
|||||||
from falyx.themes import OneColors
|
from falyx.themes import OneColors
|
||||||
from falyx.utils import ensure_async
|
from falyx.utils import ensure_async
|
||||||
|
|
||||||
console = Console(color_system="truecolor")
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseModel):
|
class Command(BaseModel):
|
||||||
"""
|
"""
|
||||||
|
@ -11,18 +11,16 @@ from typing import Any, Callable
|
|||||||
import toml
|
import toml
|
||||||
import yaml
|
import yaml
|
||||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
from rich.console import Console
|
|
||||||
|
|
||||||
from falyx.action.action import Action
|
from falyx.action.action import Action
|
||||||
from falyx.action.base_action import BaseAction
|
from falyx.action.base_action import BaseAction
|
||||||
from falyx.command import Command
|
from falyx.command import Command
|
||||||
|
from falyx.console import console
|
||||||
from falyx.falyx import Falyx
|
from falyx.falyx import Falyx
|
||||||
from falyx.logger import logger
|
from falyx.logger import logger
|
||||||
from falyx.retry import RetryPolicy
|
from falyx.retry import RetryPolicy
|
||||||
from falyx.themes import OneColors
|
from falyx.themes import OneColors
|
||||||
|
|
||||||
console = Console(color_system="truecolor")
|
|
||||||
|
|
||||||
|
|
||||||
def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command:
|
def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command:
|
||||||
if isinstance(obj, (BaseAction, Command)):
|
if isinstance(obj, (BaseAction, Command)):
|
||||||
|
5
falyx/console.py
Normal file
5
falyx/console.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from falyx.themes import get_nord_theme
|
||||||
|
|
||||||
|
console = Console(color_system="truecolor", theme=get_nord_theme())
|
@ -24,6 +24,8 @@ from typing import Any
|
|||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
|
from falyx.console import console
|
||||||
|
|
||||||
|
|
||||||
class ExecutionContext(BaseModel):
|
class ExecutionContext(BaseModel):
|
||||||
"""
|
"""
|
||||||
@ -83,7 +85,7 @@ class ExecutionContext(BaseModel):
|
|||||||
index: int | None = None
|
index: int | None = None
|
||||||
|
|
||||||
extra: dict[str, Any] = Field(default_factory=dict)
|
extra: dict[str, Any] = Field(default_factory=dict)
|
||||||
console: Console = Field(default_factory=lambda: Console(color_system="truecolor"))
|
console: Console = console
|
||||||
|
|
||||||
shared_context: SharedContext | None = None
|
shared_context: SharedContext | None = None
|
||||||
|
|
||||||
|
@ -36,6 +36,7 @@ from rich import box
|
|||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
|
from falyx.console import console
|
||||||
from falyx.context import ExecutionContext
|
from falyx.context import ExecutionContext
|
||||||
from falyx.logger import logger
|
from falyx.logger import logger
|
||||||
from falyx.themes import OneColors
|
from falyx.themes import OneColors
|
||||||
|
@ -46,6 +46,7 @@ from falyx.action.base_action import BaseAction
|
|||||||
from falyx.bottom_bar import BottomBar
|
from falyx.bottom_bar import BottomBar
|
||||||
from falyx.command import Command
|
from falyx.command import Command
|
||||||
from falyx.completer import FalyxCompleter
|
from falyx.completer import FalyxCompleter
|
||||||
|
from falyx.console import console
|
||||||
from falyx.context import ExecutionContext
|
from falyx.context import ExecutionContext
|
||||||
from falyx.debug import log_after, log_before, log_error, log_success
|
from falyx.debug import log_after, log_before, log_error, log_success
|
||||||
from falyx.exceptions import (
|
from falyx.exceptions import (
|
||||||
@ -63,7 +64,7 @@ from falyx.parser import CommandArgumentParser, FalyxParsers, get_arg_parsers
|
|||||||
from falyx.protocols import ArgParserProtocol
|
from falyx.protocols import ArgParserProtocol
|
||||||
from falyx.retry import RetryPolicy
|
from falyx.retry import RetryPolicy
|
||||||
from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
|
from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
|
||||||
from falyx.themes import OneColors, get_nord_theme
|
from falyx.themes import OneColors
|
||||||
from falyx.utils import CaseInsensitiveDict, _noop, chunks
|
from falyx.utils import CaseInsensitiveDict, _noop, chunks
|
||||||
from falyx.version import __version__
|
from falyx.version import __version__
|
||||||
|
|
||||||
@ -201,7 +202,7 @@ class Falyx:
|
|||||||
self.help_command: Command | None = (
|
self.help_command: Command | None = (
|
||||||
self._get_help_command() if include_help_command else None
|
self._get_help_command() if include_help_command else None
|
||||||
)
|
)
|
||||||
self.console: Console = Console(color_system="truecolor", theme=get_nord_theme())
|
self.console: Console = console
|
||||||
self.welcome_message: str | Markdown | dict[str, Any] = welcome_message
|
self.welcome_message: str | Markdown | dict[str, Any] = welcome_message
|
||||||
self.exit_message: str | Markdown | dict[str, Any] = exit_message
|
self.exit_message: str | Markdown | dict[str, Any] = exit_message
|
||||||
self.hooks: HookManager = HookManager()
|
self.hooks: HookManager = HookManager()
|
||||||
@ -513,6 +514,8 @@ class Falyx:
|
|||||||
bottom_toolbar=self._get_bottom_bar_render(),
|
bottom_toolbar=self._get_bottom_bar_render(),
|
||||||
key_bindings=self.key_bindings,
|
key_bindings=self.key_bindings,
|
||||||
validate_while_typing=True,
|
validate_while_typing=True,
|
||||||
|
interrupt_exception=QuitSignal,
|
||||||
|
eof_exception=QuitSignal,
|
||||||
)
|
)
|
||||||
return self._prompt_session
|
return self._prompt_session
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
"""init.py"""
|
"""init.py"""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from rich.console import Console
|
from falyx.console import console
|
||||||
|
|
||||||
TEMPLATE_TASKS = """\
|
TEMPLATE_TASKS = """\
|
||||||
# This file is used by falyx.yaml to define CLI actions.
|
# This file is used by falyx.yaml to define CLI actions.
|
||||||
@ -98,8 +98,6 @@ commands:
|
|||||||
aliases: [clean, cleanup]
|
aliases: [clean, cleanup]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
console = Console(color_system="truecolor")
|
|
||||||
|
|
||||||
|
|
||||||
def init_project(name: str) -> None:
|
def init_project(name: str) -> None:
|
||||||
target = Path(name).resolve()
|
target = Path(name).resolve()
|
||||||
|
@ -24,6 +24,7 @@ class Argument:
|
|||||||
nargs: int | str | None = None # int, '?', '*', '+', None
|
nargs: int | str | None = None # int, '?', '*', '+', None
|
||||||
positional: bool = False # True if no leading - or -- in flags
|
positional: bool = False # True if no leading - or -- in flags
|
||||||
resolver: BaseAction | None = None # Action object for the argument
|
resolver: BaseAction | None = None # Action object for the argument
|
||||||
|
lazy_resolver: bool = False # True if resolver should be called lazily
|
||||||
|
|
||||||
def get_positional_text(self) -> str:
|
def get_positional_text(self) -> str:
|
||||||
"""Get the positional text for the argument."""
|
"""Get the positional text for the argument."""
|
||||||
|
@ -9,6 +9,7 @@ from rich.console import Console
|
|||||||
from rich.markup import escape
|
from rich.markup import escape
|
||||||
|
|
||||||
from falyx.action.base_action import BaseAction
|
from falyx.action.base_action import BaseAction
|
||||||
|
from falyx.console import console
|
||||||
from falyx.exceptions import CommandArgumentError
|
from falyx.exceptions import CommandArgumentError
|
||||||
from falyx.parser.argument import Argument
|
from falyx.parser.argument import Argument
|
||||||
from falyx.parser.argument_action import ArgumentAction
|
from falyx.parser.argument_action import ArgumentAction
|
||||||
@ -46,7 +47,7 @@ class CommandArgumentParser:
|
|||||||
aliases: list[str] | None = None,
|
aliases: list[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the CommandArgumentParser."""
|
"""Initialize the CommandArgumentParser."""
|
||||||
self.console = Console(color_system="truecolor")
|
self.console: Console = console
|
||||||
self.command_key: str = command_key
|
self.command_key: str = command_key
|
||||||
self.command_description: str = command_description
|
self.command_description: str = command_description
|
||||||
self.command_style: str = command_style
|
self.command_style: str = command_style
|
||||||
@ -300,6 +301,7 @@ class CommandArgumentParser:
|
|||||||
help: str = "",
|
help: str = "",
|
||||||
dest: str | None = None,
|
dest: str | None = None,
|
||||||
resolver: BaseAction | None = None,
|
resolver: BaseAction | None = None,
|
||||||
|
lazy_resolver: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add an argument to the parser.
|
"""Add an argument to the parser.
|
||||||
For `ArgumentAction.ACTION`, `nargs` and `type` determine how many and what kind
|
For `ArgumentAction.ACTION`, `nargs` and `type` determine how many and what kind
|
||||||
@ -348,6 +350,10 @@ class CommandArgumentParser:
|
|||||||
f"Default value '{default}' not in allowed choices: {choices}"
|
f"Default value '{default}' not in allowed choices: {choices}"
|
||||||
)
|
)
|
||||||
required = self._determine_required(required, positional, nargs)
|
required = self._determine_required(required, positional, nargs)
|
||||||
|
if not isinstance(lazy_resolver, bool):
|
||||||
|
raise CommandArgumentError(
|
||||||
|
f"lazy_resolver must be a boolean, got {type(lazy_resolver)}"
|
||||||
|
)
|
||||||
argument = Argument(
|
argument = Argument(
|
||||||
flags=flags,
|
flags=flags,
|
||||||
dest=dest,
|
dest=dest,
|
||||||
@ -360,6 +366,7 @@ class CommandArgumentParser:
|
|||||||
nargs=nargs,
|
nargs=nargs,
|
||||||
positional=positional,
|
positional=positional,
|
||||||
resolver=resolver,
|
resolver=resolver,
|
||||||
|
lazy_resolver=lazy_resolver,
|
||||||
)
|
)
|
||||||
for flag in flags:
|
for flag in flags:
|
||||||
if flag in self._flag_map:
|
if flag in self._flag_map:
|
||||||
@ -445,6 +452,7 @@ class CommandArgumentParser:
|
|||||||
result: dict[str, Any],
|
result: dict[str, Any],
|
||||||
positional_args: list[Argument],
|
positional_args: list[Argument],
|
||||||
consumed_positional_indicies: set[int],
|
consumed_positional_indicies: set[int],
|
||||||
|
from_validate: bool = False,
|
||||||
) -> int:
|
) -> int:
|
||||||
remaining_positional_args = [
|
remaining_positional_args = [
|
||||||
(j, spec)
|
(j, spec)
|
||||||
@ -508,6 +516,7 @@ class CommandArgumentParser:
|
|||||||
assert isinstance(
|
assert isinstance(
|
||||||
spec.resolver, BaseAction
|
spec.resolver, BaseAction
|
||||||
), "resolver should be an instance of BaseAction"
|
), "resolver should be an instance of BaseAction"
|
||||||
|
if not spec.lazy_resolver or not from_validate:
|
||||||
try:
|
try:
|
||||||
result[spec.dest] = await spec.resolver(*typed)
|
result[spec.dest] = await spec.resolver(*typed)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
@ -657,18 +666,22 @@ class CommandArgumentParser:
|
|||||||
if not typed_values and spec.nargs not in ("*", "?"):
|
if not typed_values and spec.nargs not in ("*", "?"):
|
||||||
choices = []
|
choices = []
|
||||||
if spec.default:
|
if spec.default:
|
||||||
choices.append(f"default={spec.default!r}")
|
choices.append(f"default={spec.default}")
|
||||||
if spec.choices:
|
if spec.choices:
|
||||||
choices.append(f"choices={spec.choices!r}")
|
choices.append(f"choices={spec.choices}")
|
||||||
if choices:
|
if choices:
|
||||||
choices_text = ", ".join(choices)
|
choices_text = ", ".join(choices)
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
f"Argument '{spec.dest}' requires a value. {choices_text}"
|
f"Argument '{spec.dest}' requires a value. {choices_text}"
|
||||||
)
|
)
|
||||||
if spec.nargs is None:
|
elif spec.nargs is None:
|
||||||
|
try:
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
f"Enter a {spec.type.__name__} value for '{spec.dest}'"
|
f"Enter a {spec.type.__name__} value for '{spec.dest}'"
|
||||||
)
|
)
|
||||||
|
except AttributeError:
|
||||||
|
raise CommandArgumentError(f"Enter a value for '{spec.dest}'")
|
||||||
|
else:
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
f"Argument '{spec.dest}' requires a value. Expected {spec.nargs} values."
|
f"Argument '{spec.dest}' requires a value. Expected {spec.nargs} values."
|
||||||
)
|
)
|
||||||
@ -705,6 +718,7 @@ class CommandArgumentParser:
|
|||||||
result,
|
result,
|
||||||
positional_args,
|
positional_args,
|
||||||
consumed_positional_indices,
|
consumed_positional_indices,
|
||||||
|
from_validate=from_validate,
|
||||||
)
|
)
|
||||||
i += args_consumed
|
i += args_consumed
|
||||||
return i
|
return i
|
||||||
@ -746,13 +760,19 @@ class CommandArgumentParser:
|
|||||||
continue
|
continue
|
||||||
if spec.required and not result.get(spec.dest):
|
if spec.required and not result.get(spec.dest):
|
||||||
help_text = f" help: {spec.help}" if spec.help else ""
|
help_text = f" help: {spec.help}" if spec.help else ""
|
||||||
|
if (
|
||||||
|
spec.action == ArgumentAction.ACTION
|
||||||
|
and spec.lazy_resolver
|
||||||
|
and from_validate
|
||||||
|
):
|
||||||
|
continue # Lazy resolvers are not validated here
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
f"Missing required argument {spec.dest}: {spec.get_choice_text()}{help_text}"
|
f"Missing required argument '{spec.dest}': {spec.get_choice_text()}{help_text}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if spec.choices and result.get(spec.dest) not in spec.choices:
|
if spec.choices and result.get(spec.dest) not in spec.choices:
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
f"Invalid value for {spec.dest}: must be one of {spec.choices}"
|
f"Invalid value for '{spec.dest}': must be one of {{{', '.join(spec.choices)}}}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if spec.action == ArgumentAction.ACTION:
|
if spec.action == ArgumentAction.ACTION:
|
||||||
@ -761,23 +781,23 @@ class CommandArgumentParser:
|
|||||||
if isinstance(spec.nargs, int) and spec.nargs > 1:
|
if isinstance(spec.nargs, int) and spec.nargs > 1:
|
||||||
assert isinstance(
|
assert isinstance(
|
||||||
result.get(spec.dest), list
|
result.get(spec.dest), list
|
||||||
), f"Invalid value for {spec.dest}: expected a list"
|
), f"Invalid value for '{spec.dest}': expected a list"
|
||||||
if not result[spec.dest] and not spec.required:
|
if not result[spec.dest] and not spec.required:
|
||||||
continue
|
continue
|
||||||
if spec.action == ArgumentAction.APPEND:
|
if spec.action == ArgumentAction.APPEND:
|
||||||
for group in result[spec.dest]:
|
for group in result[spec.dest]:
|
||||||
if len(group) % spec.nargs != 0:
|
if len(group) % spec.nargs != 0:
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
f"Invalid number of values for {spec.dest}: expected a multiple of {spec.nargs}"
|
f"Invalid number of values for '{spec.dest}': expected a multiple of {spec.nargs}"
|
||||||
)
|
)
|
||||||
elif spec.action == ArgumentAction.EXTEND:
|
elif spec.action == ArgumentAction.EXTEND:
|
||||||
if len(result[spec.dest]) % spec.nargs != 0:
|
if len(result[spec.dest]) % spec.nargs != 0:
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
f"Invalid number of values for {spec.dest}: expected a multiple of {spec.nargs}"
|
f"Invalid number of values for '{spec.dest}': expected a multiple of {spec.nargs}"
|
||||||
)
|
)
|
||||||
elif len(result[spec.dest]) != spec.nargs:
|
elif len(result[spec.dest]) != spec.nargs:
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
f"Invalid number of values for {spec.dest}: expected {spec.nargs}, got {len(result[spec.dest])}"
|
f"Invalid number of values for '{spec.dest}': expected {spec.nargs}, got {len(result[spec.dest])}"
|
||||||
)
|
)
|
||||||
|
|
||||||
result.pop("help", None)
|
result.pop("help", None)
|
||||||
|
@ -37,7 +37,8 @@ def coerce_enum(value: Any, enum_type: EnumMeta) -> Any:
|
|||||||
coerced_value = base_type(value)
|
coerced_value = base_type(value)
|
||||||
return enum_type(coerced_value)
|
return enum_type(coerced_value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
raise ValueError(f"Value '{value}' could not be coerced to enum type {enum_type}")
|
values = [str(enum.value) for enum in enum_type]
|
||||||
|
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: type) -> Any:
|
||||||
@ -57,7 +58,7 @@ def coerce_value(value: str, target_type: type) -> Any:
|
|||||||
return coerce_value(value, arg)
|
return coerce_value(value, arg)
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
raise ValueError(f"Value '{value}' could not be coerced to any of {args!r}")
|
raise ValueError(f"Value '{value}' could not be coerced to any of {args}")
|
||||||
|
|
||||||
if isinstance(target_type, EnumMeta):
|
if isinstance(target_type, EnumMeta):
|
||||||
return coerce_enum(value, target_type)
|
return coerce_enum(value, target_type)
|
||||||
|
@ -5,10 +5,10 @@ from typing import Any, Callable, KeysView, Sequence
|
|||||||
|
|
||||||
from prompt_toolkit import PromptSession
|
from prompt_toolkit import PromptSession
|
||||||
from rich import box
|
from rich import box
|
||||||
from rich.console import Console
|
|
||||||
from rich.markup import escape
|
from rich.markup import escape
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
|
from falyx.console import console
|
||||||
from falyx.themes import OneColors
|
from falyx.themes import OneColors
|
||||||
from falyx.utils import CaseInsensitiveDict, chunks
|
from falyx.utils import CaseInsensitiveDict, chunks
|
||||||
from falyx.validators import MultiIndexValidator, MultiKeyValidator
|
from falyx.validators import MultiIndexValidator, MultiKeyValidator
|
||||||
@ -267,7 +267,6 @@ async def prompt_for_index(
|
|||||||
*,
|
*,
|
||||||
min_index: int = 0,
|
min_index: int = 0,
|
||||||
default_selection: str = "",
|
default_selection: str = "",
|
||||||
console: Console | None = None,
|
|
||||||
prompt_session: PromptSession | None = None,
|
prompt_session: PromptSession | None = None,
|
||||||
prompt_message: str = "Select an option > ",
|
prompt_message: str = "Select an option > ",
|
||||||
show_table: bool = True,
|
show_table: bool = True,
|
||||||
@ -277,7 +276,6 @@ async def prompt_for_index(
|
|||||||
cancel_key: str = "",
|
cancel_key: str = "",
|
||||||
) -> int | list[int]:
|
) -> int | list[int]:
|
||||||
prompt_session = prompt_session or PromptSession()
|
prompt_session = prompt_session or PromptSession()
|
||||||
console = console or Console(color_system="truecolor")
|
|
||||||
|
|
||||||
if show_table:
|
if show_table:
|
||||||
console.print(table, justify="center")
|
console.print(table, justify="center")
|
||||||
@ -307,7 +305,6 @@ async def prompt_for_selection(
|
|||||||
table: Table,
|
table: Table,
|
||||||
*,
|
*,
|
||||||
default_selection: str = "",
|
default_selection: str = "",
|
||||||
console: Console | None = None,
|
|
||||||
prompt_session: PromptSession | None = None,
|
prompt_session: PromptSession | None = None,
|
||||||
prompt_message: str = "Select an option > ",
|
prompt_message: str = "Select an option > ",
|
||||||
show_table: bool = True,
|
show_table: bool = True,
|
||||||
@ -318,7 +315,6 @@ async def prompt_for_selection(
|
|||||||
) -> str | list[str]:
|
) -> str | list[str]:
|
||||||
"""Prompt the user to select a key from a set of options. Return the selected key."""
|
"""Prompt the user to select a key from a set of options. Return the selected key."""
|
||||||
prompt_session = prompt_session or PromptSession()
|
prompt_session = prompt_session or PromptSession()
|
||||||
console = console or Console(color_system="truecolor")
|
|
||||||
|
|
||||||
if show_table:
|
if show_table:
|
||||||
console.print(table, justify="center")
|
console.print(table, justify="center")
|
||||||
@ -342,7 +338,6 @@ async def select_value_from_list(
|
|||||||
title: str,
|
title: str,
|
||||||
selections: Sequence[str],
|
selections: Sequence[str],
|
||||||
*,
|
*,
|
||||||
console: Console | None = None,
|
|
||||||
prompt_session: PromptSession | None = None,
|
prompt_session: PromptSession | None = None,
|
||||||
prompt_message: str = "Select an option > ",
|
prompt_message: str = "Select an option > ",
|
||||||
default_selection: str = "",
|
default_selection: str = "",
|
||||||
@ -381,13 +376,11 @@ async def select_value_from_list(
|
|||||||
highlight=highlight,
|
highlight=highlight,
|
||||||
)
|
)
|
||||||
prompt_session = prompt_session or PromptSession()
|
prompt_session = prompt_session or PromptSession()
|
||||||
console = console or Console(color_system="truecolor")
|
|
||||||
|
|
||||||
selection_index = await prompt_for_index(
|
selection_index = await prompt_for_index(
|
||||||
len(selections) - 1,
|
len(selections) - 1,
|
||||||
table,
|
table,
|
||||||
default_selection=default_selection,
|
default_selection=default_selection,
|
||||||
console=console,
|
|
||||||
prompt_session=prompt_session,
|
prompt_session=prompt_session,
|
||||||
prompt_message=prompt_message,
|
prompt_message=prompt_message,
|
||||||
number_selections=number_selections,
|
number_selections=number_selections,
|
||||||
@ -405,7 +398,6 @@ async def select_key_from_dict(
|
|||||||
selections: dict[str, SelectionOption],
|
selections: dict[str, SelectionOption],
|
||||||
table: Table,
|
table: Table,
|
||||||
*,
|
*,
|
||||||
console: Console | None = None,
|
|
||||||
prompt_session: PromptSession | None = None,
|
prompt_session: PromptSession | None = None,
|
||||||
prompt_message: str = "Select an option > ",
|
prompt_message: str = "Select an option > ",
|
||||||
default_selection: str = "",
|
default_selection: str = "",
|
||||||
@ -416,7 +408,6 @@ async def select_key_from_dict(
|
|||||||
) -> str | list[str]:
|
) -> str | list[str]:
|
||||||
"""Prompt for a key from a dict, returns the key."""
|
"""Prompt for a key from a dict, returns the key."""
|
||||||
prompt_session = prompt_session or PromptSession()
|
prompt_session = prompt_session or PromptSession()
|
||||||
console = console or Console(color_system="truecolor")
|
|
||||||
|
|
||||||
console.print(table, justify="center")
|
console.print(table, justify="center")
|
||||||
|
|
||||||
@ -424,7 +415,6 @@ async def select_key_from_dict(
|
|||||||
selections.keys(),
|
selections.keys(),
|
||||||
table,
|
table,
|
||||||
default_selection=default_selection,
|
default_selection=default_selection,
|
||||||
console=console,
|
|
||||||
prompt_session=prompt_session,
|
prompt_session=prompt_session,
|
||||||
prompt_message=prompt_message,
|
prompt_message=prompt_message,
|
||||||
number_selections=number_selections,
|
number_selections=number_selections,
|
||||||
@ -438,7 +428,6 @@ async def select_value_from_dict(
|
|||||||
selections: dict[str, SelectionOption],
|
selections: dict[str, SelectionOption],
|
||||||
table: Table,
|
table: Table,
|
||||||
*,
|
*,
|
||||||
console: Console | None = None,
|
|
||||||
prompt_session: PromptSession | None = None,
|
prompt_session: PromptSession | None = None,
|
||||||
prompt_message: str = "Select an option > ",
|
prompt_message: str = "Select an option > ",
|
||||||
default_selection: str = "",
|
default_selection: str = "",
|
||||||
@ -449,7 +438,6 @@ async def select_value_from_dict(
|
|||||||
) -> Any | list[Any]:
|
) -> Any | list[Any]:
|
||||||
"""Prompt for a key from a dict, but return the value."""
|
"""Prompt for a key from a dict, but return the value."""
|
||||||
prompt_session = prompt_session or PromptSession()
|
prompt_session = prompt_session or PromptSession()
|
||||||
console = console or Console(color_system="truecolor")
|
|
||||||
|
|
||||||
console.print(table, justify="center")
|
console.print(table, justify="center")
|
||||||
|
|
||||||
@ -457,7 +445,6 @@ async def select_value_from_dict(
|
|||||||
selections.keys(),
|
selections.keys(),
|
||||||
table,
|
table,
|
||||||
default_selection=default_selection,
|
default_selection=default_selection,
|
||||||
console=console,
|
|
||||||
prompt_session=prompt_session,
|
prompt_session=prompt_session,
|
||||||
prompt_message=prompt_message,
|
prompt_message=prompt_message,
|
||||||
number_selections=number_selections,
|
number_selections=number_selections,
|
||||||
@ -475,7 +462,6 @@ async def get_selection_from_dict_menu(
|
|||||||
title: str,
|
title: str,
|
||||||
selections: dict[str, SelectionOption],
|
selections: dict[str, SelectionOption],
|
||||||
*,
|
*,
|
||||||
console: Console | None = None,
|
|
||||||
prompt_session: PromptSession | None = None,
|
prompt_session: PromptSession | None = None,
|
||||||
prompt_message: str = "Select an option > ",
|
prompt_message: str = "Select an option > ",
|
||||||
default_selection: str = "",
|
default_selection: str = "",
|
||||||
@ -493,7 +479,6 @@ async def get_selection_from_dict_menu(
|
|||||||
return await select_value_from_dict(
|
return await select_value_from_dict(
|
||||||
selections=selections,
|
selections=selections,
|
||||||
table=table,
|
table=table,
|
||||||
console=console,
|
|
||||||
prompt_session=prompt_session,
|
prompt_session=prompt_session,
|
||||||
prompt_message=prompt_message,
|
prompt_message=prompt_message,
|
||||||
default_selection=default_selection,
|
default_selection=default_selection,
|
||||||
|
@ -47,6 +47,30 @@ def yes_no_validator() -> Validator:
|
|||||||
return Validator.from_callable(validate, error_message="Enter 'Y' or 'n'.")
|
return Validator.from_callable(validate, error_message="Enter 'Y' or 'n'.")
|
||||||
|
|
||||||
|
|
||||||
|
def words_validator(keys: Sequence[str] | KeysView[str]) -> Validator:
|
||||||
|
"""Validator for specific word inputs."""
|
||||||
|
|
||||||
|
def validate(text: str) -> bool:
|
||||||
|
if text.upper() not in [key.upper() for key in keys]:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
return Validator.from_callable(
|
||||||
|
validate, error_message=f"Invalid input. Choices: {{{', '.join(keys)}}}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def word_validator(word: str) -> Validator:
|
||||||
|
"""Validator for specific word inputs."""
|
||||||
|
|
||||||
|
def validate(text: str) -> bool:
|
||||||
|
if text.upper().strip() == "N":
|
||||||
|
return True
|
||||||
|
return text.upper().strip() == word.upper()
|
||||||
|
|
||||||
|
return Validator.from_callable(validate, error_message=f"Enter '{word}' or 'N'.")
|
||||||
|
|
||||||
|
|
||||||
class MultiIndexValidator(Validator):
|
class MultiIndexValidator(Validator):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
@ -1 +1 @@
|
|||||||
__version__ = "0.1.56"
|
__version__ = "0.1.57"
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "falyx"
|
name = "falyx"
|
||||||
version = "0.1.56"
|
version = "0.1.57"
|
||||||
description = "Reliable and introspectable async CLI action framework."
|
description = "Reliable and introspectable async CLI action framework."
|
||||||
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
|
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
@ -23,3 +23,18 @@ async def test_multiple_positional_with_default():
|
|||||||
args = await parser.parse_args(["a", "b", "c"])
|
args = await parser.parse_args(["a", "b", "c"])
|
||||||
assert args["files"] == ["a", "b", "c"]
|
assert args["files"] == ["a", "b", "c"]
|
||||||
assert args["mode"] == "edit"
|
assert args["mode"] == "edit"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_multiple_positional_with_double_default():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
parser.add_argument("files", nargs="+", default=["a", "b", "c"])
|
||||||
|
parser.add_argument("mode", choices=["edit", "view"], default="edit")
|
||||||
|
|
||||||
|
args = await parser.parse_args()
|
||||||
|
assert args["files"] == ["a", "b", "c"]
|
||||||
|
assert args["mode"] == "edit"
|
||||||
|
|
||||||
|
args = await parser.parse_args(["a", "b"])
|
||||||
|
assert args["files"] == ["a", "b"]
|
||||||
|
assert args["mode"] == "edit"
|
||||||
|
Reference in New Issue
Block a user