Add falyx.console for single rich.console.Console instance, Add ConfirmAction, SaveFileAction, Add lazy evaluation for ArgumentAction.ACTION

This commit is contained in:
2025-07-12 11:52:02 -04:00
parent ed42f6488e
commit 4c1498121f
30 changed files with 689 additions and 101 deletions

108
examples/confirm_example.py Normal file
View 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())

View File

@ -12,6 +12,7 @@ from .falyx import Falyx
logger = logging.getLogger("falyx")
__all__ = [
"Falyx",
"ExecutionRegistry",

View File

@ -10,6 +10,7 @@ from .action_factory import ActionFactory
from .action_group import ActionGroup
from .base_action import BaseAction
from .chained_action import ChainedAction
from .confirm_action import ConfirmAction
from .fallback_action import FallbackAction
from .http_action import HTTPAction
from .io_action import BaseIOAction
@ -19,6 +20,7 @@ from .menu_action import MenuAction
from .process_action import ProcessAction
from .process_pool_action import ProcessPoolAction
from .prompt_menu_action import PromptMenuAction
from .save_file_action import SaveFileAction
from .select_file_action import SelectFileAction
from .selection_action import SelectionAction
from .shell_action import ShellAction
@ -45,4 +47,6 @@ __all__ = [
"PromptMenuAction",
"ProcessPoolAction",
"LoadFileAction",
"SaveFileAction",
"ConfirmAction",
]

View File

@ -14,6 +14,7 @@ from falyx.exceptions import EmptyGroupError
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import Hook, HookManager, HookType
from falyx.logger import logger
from falyx.options_manager import OptionsManager
from falyx.parser.utils import same_argument_definitions
from falyx.themes.colors import OneColors
@ -96,6 +97,11 @@ class ActionGroup(BaseAction, ActionListMixin):
for action in actions:
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]:
arg_defs = same_argument_definitions(self.actions)
if arg_defs:

View File

@ -36,6 +36,7 @@ from typing import Any, Callable
from rich.console import Console
from rich.tree import Tree
from falyx.console import console
from falyx.context import SharedContext
from falyx.debug import register_debug_hooks
from falyx.hook_manager import Hook, HookManager, HookType
@ -73,7 +74,7 @@ class BaseAction(ABC):
self.inject_into: str = inject_into
self._never_prompt: bool = never_prompt
self._skip_in_chain: bool = False
self.console = Console(color_system="truecolor")
self.console: Console = console
self.options_manager: OptionsManager | None = None
if logging_hooks:

View File

@ -16,6 +16,7 @@ from falyx.exceptions import EmptyChainError
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import Hook, HookManager, HookType
from falyx.logger import logger
from falyx.options_manager import OptionsManager
from falyx.themes import OneColors
@ -92,6 +93,11 @@ class ChainedAction(BaseAction, ActionListMixin):
for action in actions:
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]:
if self.actions:
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):
"""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:
action.register_hooks_recursively(hook_type, hook)

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

View File

@ -80,9 +80,14 @@ class LoadFileAction(BaseAction):
def get_infer_target(self) -> tuple[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:
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
try:
if self.file_type == FileType.TEXT:
@ -125,14 +130,7 @@ class LoadFileAction(BaseAction):
elif self.inject_last_result and self.last_result:
self.file_path = self.last_result
if self.file_path is None:
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()
result = await self.load_file()
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return result
except Exception as error:

View File

@ -3,7 +3,6 @@
from typing import Any
from prompt_toolkit import PromptSession
from rich.console import Console
from rich.table import Table
from rich.tree import Tree
@ -33,7 +32,6 @@ class MenuAction(BaseAction):
default_selection: str = "",
inject_last_result: bool = False,
inject_into: str = "last_result",
console: Console | None = None,
prompt_session: PromptSession | None = None,
never_prompt: bool = False,
include_reserved: bool = True,
@ -51,10 +49,6 @@ class MenuAction(BaseAction):
self.columns = columns
self.prompt_message = prompt_message
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.include_reserved = include_reserved
self.show_table = show_table
@ -115,7 +109,6 @@ class MenuAction(BaseAction):
self.menu_options.keys(),
table,
default_selection=self.default_selection,
console=self.console,
prompt_session=self.prompt_session,
prompt_message=self.prompt_message,
show_table=self.show_table,

View File

@ -4,7 +4,6 @@ from typing import Any
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import FormattedText, merge_formatted_text
from rich.console import Console
from rich.tree import Tree
from falyx.action.base_action import BaseAction
@ -29,7 +28,6 @@ class PromptMenuAction(BaseAction):
default_selection: str = "",
inject_last_result: bool = False,
inject_into: str = "last_result",
console: Console | None = None,
prompt_session: PromptSession | None = None,
never_prompt: bool = False,
include_reserved: bool = True,
@ -43,10 +41,6 @@ class PromptMenuAction(BaseAction):
self.menu_options = menu_options
self.prompt_message = prompt_message
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.include_reserved = include_reserved

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

View File

@ -11,7 +11,6 @@ from typing import Any
import toml
import yaml
from prompt_toolkit import PromptSession
from rich.console import Console
from rich.tree import Tree
from falyx.action.action_types import FileType
@ -51,7 +50,6 @@ class SelectFileAction(BaseAction):
style (str): Style for the selection options.
suffix_filter (str | None): Restrict to certain file types.
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.
"""
@ -69,7 +67,6 @@ class SelectFileAction(BaseAction):
number_selections: int | str = 1,
separator: str = ",",
allow_duplicates: bool = False,
console: Console | None = None,
prompt_session: PromptSession | None = None,
):
super().__init__(name)
@ -82,10 +79,6 @@ class SelectFileAction(BaseAction):
self.number_selections = number_selections
self.separator = separator
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.return_type = self._coerce_return_type(return_type)
@ -195,7 +188,6 @@ class SelectFileAction(BaseAction):
keys = await prompt_for_selection(
(options | cancel_option).keys(),
table,
console=self.console,
prompt_session=self.prompt_session,
prompt_message=self.prompt_message,
number_selections=self.number_selections,

View File

@ -3,7 +3,6 @@
from typing import Any
from prompt_toolkit import PromptSession
from rich.console import Console
from rich.tree import Tree
from falyx.action.action_types import SelectionReturnType
@ -54,7 +53,6 @@ class SelectionAction(BaseAction):
inject_last_result: bool = False,
inject_into: str = "last_result",
return_type: SelectionReturnType | str = "value",
console: Console | None = None,
prompt_session: PromptSession | None = None,
never_prompt: bool = False,
show_table: bool = True,
@ -70,10 +68,6 @@ class SelectionAction(BaseAction):
self.return_type: SelectionReturnType = self._coerce_return_type(return_type)
self.title = title
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.default_selection = default_selection
self.number_selections = number_selections
@ -262,7 +256,6 @@ class SelectionAction(BaseAction):
len(self.selections),
table,
default_selection=effective_default,
console=self.console,
prompt_session=self.prompt_session,
prompt_message=self.prompt_message,
show_table=self.show_table,
@ -306,7 +299,6 @@ class SelectionAction(BaseAction):
(self.selections | cancel_option).keys(),
table,
default_selection=effective_default,
console=self.console,
prompt_session=self.prompt_session,
prompt_message=self.prompt_message,
show_table=self.show_table,

View File

@ -2,7 +2,6 @@
"""user_input_action.py"""
from prompt_toolkit import PromptSession
from prompt_toolkit.validation import Validator
from rich.console import Console
from rich.tree import Tree
from falyx.action.base_action import BaseAction
@ -20,7 +19,6 @@ class UserInputAction(BaseAction):
name (str): Action name.
prompt_text (str): Prompt text (can include '{last_result}' for interpolation).
validator (Validator, optional): Prompt Toolkit validator.
console (Console, optional): Rich console for rendering.
prompt_session (PromptSession, optional): Reusable prompt session.
inject_last_result (bool): Whether to inject last_result into prompt.
inject_into (str): Key to use for injection (default: 'last_result').
@ -33,7 +31,6 @@ class UserInputAction(BaseAction):
prompt_text: str = "Input > ",
default_text: str = "",
validator: Validator | None = None,
console: Console | None = None,
prompt_session: PromptSession | None = None,
inject_last_result: bool = False,
):
@ -43,10 +40,6 @@ class UserInputAction(BaseAction):
)
self.prompt_text = prompt_text
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.default_text = default_text

View File

@ -5,8 +5,8 @@ from typing import Any, Callable
from prompt_toolkit.formatted_text import HTML, merge_formatted_text
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.themes import OneColors
from falyx.utils import CaseInsensitiveDict, chunks
@ -30,7 +30,7 @@ class BottomBar:
key_validator: Callable[[str], bool] | None = None,
) -> None:
self.columns = columns
self.console = Console(color_system="truecolor")
self.console: Console = console
self._named_items: dict[str, Callable[[], HTML]] = {}
self._value_getters: dict[str, Callable[[], Any]] = CaseInsensitiveDict()
self.toggle_keys: list[str] = []

View File

@ -23,11 +23,11 @@ from typing import Any, Awaitable, Callable
from prompt_toolkit.formatted_text import FormattedText
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
from rich.console import Console
from rich.tree import Tree
from falyx.action.action import Action
from falyx.action.base_action import BaseAction
from falyx.console import console
from falyx.context import ExecutionContext
from falyx.debug import register_debug_hooks
from falyx.execution_registry import ExecutionRegistry as er
@ -44,8 +44,6 @@ from falyx.signals import CancelSignal
from falyx.themes import OneColors
from falyx.utils import ensure_async
console = Console(color_system="truecolor")
class Command(BaseModel):
"""

View File

@ -11,18 +11,16 @@ from typing import Any, Callable
import toml
import yaml
from pydantic import BaseModel, Field, field_validator, model_validator
from rich.console import Console
from falyx.action.action import Action
from falyx.action.base_action import BaseAction
from falyx.command import Command
from falyx.console import console
from falyx.falyx import Falyx
from falyx.logger import logger
from falyx.retry import RetryPolicy
from falyx.themes import OneColors
console = Console(color_system="truecolor")
def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command:
if isinstance(obj, (BaseAction, Command)):

5
falyx/console.py Normal file
View 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())

View File

@ -24,6 +24,8 @@ from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from rich.console import Console
from falyx.console import console
class ExecutionContext(BaseModel):
"""
@ -83,7 +85,7 @@ class ExecutionContext(BaseModel):
index: int | None = None
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

View File

@ -36,6 +36,7 @@ from rich import box
from rich.console import Console
from rich.table import Table
from falyx.console import console
from falyx.context import ExecutionContext
from falyx.logger import logger
from falyx.themes import OneColors

View File

@ -46,6 +46,7 @@ from falyx.action.base_action import BaseAction
from falyx.bottom_bar import BottomBar
from falyx.command import Command
from falyx.completer import FalyxCompleter
from falyx.console import console
from falyx.context import ExecutionContext
from falyx.debug import log_after, log_before, log_error, log_success
from falyx.exceptions import (
@ -63,7 +64,7 @@ from falyx.parser import CommandArgumentParser, FalyxParsers, get_arg_parsers
from falyx.protocols import ArgParserProtocol
from falyx.retry import RetryPolicy
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.version import __version__
@ -201,7 +202,7 @@ class Falyx:
self.help_command: Command | 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.exit_message: str | Markdown | dict[str, Any] = exit_message
self.hooks: HookManager = HookManager()
@ -513,6 +514,8 @@ class Falyx:
bottom_toolbar=self._get_bottom_bar_render(),
key_bindings=self.key_bindings,
validate_while_typing=True,
interrupt_exception=QuitSignal,
eof_exception=QuitSignal,
)
return self._prompt_session

View File

@ -2,7 +2,7 @@
"""init.py"""
from pathlib import Path
from rich.console import Console
from falyx.console import console
TEMPLATE_TASKS = """\
# This file is used by falyx.yaml to define CLI actions.
@ -98,8 +98,6 @@ commands:
aliases: [clean, cleanup]
"""
console = Console(color_system="truecolor")
def init_project(name: str) -> None:
target = Path(name).resolve()

View File

@ -24,6 +24,7 @@ class Argument:
nargs: int | str | None = None # int, '?', '*', '+', None
positional: bool = False # True if no leading - or -- in flags
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:
"""Get the positional text for the argument."""

View File

@ -9,6 +9,7 @@ from rich.console import Console
from rich.markup import escape
from falyx.action.base_action import BaseAction
from falyx.console import console
from falyx.exceptions import CommandArgumentError
from falyx.parser.argument import Argument
from falyx.parser.argument_action import ArgumentAction
@ -46,7 +47,7 @@ class CommandArgumentParser:
aliases: list[str] | None = None,
) -> None:
"""Initialize the CommandArgumentParser."""
self.console = Console(color_system="truecolor")
self.console: Console = console
self.command_key: str = command_key
self.command_description: str = command_description
self.command_style: str = command_style
@ -300,6 +301,7 @@ class CommandArgumentParser:
help: str = "",
dest: str | None = None,
resolver: BaseAction | None = None,
lazy_resolver: bool = False,
) -> None:
"""Add an argument to the parser.
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}"
)
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(
flags=flags,
dest=dest,
@ -360,6 +366,7 @@ class CommandArgumentParser:
nargs=nargs,
positional=positional,
resolver=resolver,
lazy_resolver=lazy_resolver,
)
for flag in flags:
if flag in self._flag_map:
@ -445,6 +452,7 @@ class CommandArgumentParser:
result: dict[str, Any],
positional_args: list[Argument],
consumed_positional_indicies: set[int],
from_validate: bool = False,
) -> int:
remaining_positional_args = [
(j, spec)
@ -508,6 +516,7 @@ class CommandArgumentParser:
assert isinstance(
spec.resolver, BaseAction
), "resolver should be an instance of BaseAction"
if not spec.lazy_resolver or not from_validate:
try:
result[spec.dest] = await spec.resolver(*typed)
except Exception as error:
@ -657,18 +666,22 @@ class CommandArgumentParser:
if not typed_values and spec.nargs not in ("*", "?"):
choices = []
if spec.default:
choices.append(f"default={spec.default!r}")
choices.append(f"default={spec.default}")
if spec.choices:
choices.append(f"choices={spec.choices!r}")
choices.append(f"choices={spec.choices}")
if choices:
choices_text = ", ".join(choices)
raise CommandArgumentError(
f"Argument '{spec.dest}' requires a value. {choices_text}"
)
if spec.nargs is None:
elif spec.nargs is None:
try:
raise CommandArgumentError(
f"Enter a {spec.type.__name__} value for '{spec.dest}'"
)
except AttributeError:
raise CommandArgumentError(f"Enter a value for '{spec.dest}'")
else:
raise CommandArgumentError(
f"Argument '{spec.dest}' requires a value. Expected {spec.nargs} values."
)
@ -705,6 +718,7 @@ class CommandArgumentParser:
result,
positional_args,
consumed_positional_indices,
from_validate=from_validate,
)
i += args_consumed
return i
@ -746,13 +760,19 @@ class CommandArgumentParser:
continue
if spec.required and not result.get(spec.dest):
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(
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:
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:
@ -761,23 +781,23 @@ class CommandArgumentParser:
if isinstance(spec.nargs, int) and spec.nargs > 1:
assert isinstance(
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:
continue
if spec.action == ArgumentAction.APPEND:
for group in result[spec.dest]:
if len(group) % spec.nargs != 0:
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:
if len(result[spec.dest]) % spec.nargs != 0:
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:
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)

View File

@ -37,7 +37,8 @@ def coerce_enum(value: Any, enum_type: EnumMeta) -> Any:
coerced_value = base_type(value)
return enum_type(coerced_value)
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:
@ -57,7 +58,7 @@ def coerce_value(value: str, target_type: type) -> Any:
return coerce_value(value, arg)
except Exception:
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):
return coerce_enum(value, target_type)

View File

@ -5,10 +5,10 @@ from typing import Any, Callable, KeysView, Sequence
from prompt_toolkit import PromptSession
from rich import box
from rich.console import Console
from rich.markup import escape
from rich.table import Table
from falyx.console import console
from falyx.themes import OneColors
from falyx.utils import CaseInsensitiveDict, chunks
from falyx.validators import MultiIndexValidator, MultiKeyValidator
@ -267,7 +267,6 @@ async def prompt_for_index(
*,
min_index: int = 0,
default_selection: str = "",
console: Console | None = None,
prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ",
show_table: bool = True,
@ -277,7 +276,6 @@ async def prompt_for_index(
cancel_key: str = "",
) -> int | list[int]:
prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="truecolor")
if show_table:
console.print(table, justify="center")
@ -307,7 +305,6 @@ async def prompt_for_selection(
table: Table,
*,
default_selection: str = "",
console: Console | None = None,
prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ",
show_table: bool = True,
@ -318,7 +315,6 @@ async def prompt_for_selection(
) -> str | list[str]:
"""Prompt the user to select a key from a set of options. Return the selected key."""
prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="truecolor")
if show_table:
console.print(table, justify="center")
@ -342,7 +338,6 @@ async def select_value_from_list(
title: str,
selections: Sequence[str],
*,
console: Console | None = None,
prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ",
default_selection: str = "",
@ -381,13 +376,11 @@ async def select_value_from_list(
highlight=highlight,
)
prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="truecolor")
selection_index = await prompt_for_index(
len(selections) - 1,
table,
default_selection=default_selection,
console=console,
prompt_session=prompt_session,
prompt_message=prompt_message,
number_selections=number_selections,
@ -405,7 +398,6 @@ async def select_key_from_dict(
selections: dict[str, SelectionOption],
table: Table,
*,
console: Console | None = None,
prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ",
default_selection: str = "",
@ -416,7 +408,6 @@ async def select_key_from_dict(
) -> str | list[str]:
"""Prompt for a key from a dict, returns the key."""
prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="truecolor")
console.print(table, justify="center")
@ -424,7 +415,6 @@ async def select_key_from_dict(
selections.keys(),
table,
default_selection=default_selection,
console=console,
prompt_session=prompt_session,
prompt_message=prompt_message,
number_selections=number_selections,
@ -438,7 +428,6 @@ async def select_value_from_dict(
selections: dict[str, SelectionOption],
table: Table,
*,
console: Console | None = None,
prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ",
default_selection: str = "",
@ -449,7 +438,6 @@ async def select_value_from_dict(
) -> Any | list[Any]:
"""Prompt for a key from a dict, but return the value."""
prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="truecolor")
console.print(table, justify="center")
@ -457,7 +445,6 @@ async def select_value_from_dict(
selections.keys(),
table,
default_selection=default_selection,
console=console,
prompt_session=prompt_session,
prompt_message=prompt_message,
number_selections=number_selections,
@ -475,7 +462,6 @@ async def get_selection_from_dict_menu(
title: str,
selections: dict[str, SelectionOption],
*,
console: Console | None = None,
prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ",
default_selection: str = "",
@ -493,7 +479,6 @@ async def get_selection_from_dict_menu(
return await select_value_from_dict(
selections=selections,
table=table,
console=console,
prompt_session=prompt_session,
prompt_message=prompt_message,
default_selection=default_selection,

View File

@ -47,6 +47,30 @@ def yes_no_validator() -> Validator:
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):
def __init__(
self,

View File

@ -1 +1 @@
__version__ = "0.1.56"
__version__ = "0.1.57"

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "falyx"
version = "0.1.56"
version = "0.1.57"
description = "Reliable and introspectable async CLI action framework."
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
license = "MIT"

View File

@ -23,3 +23,18 @@ async def test_multiple_positional_with_default():
args = await parser.parse_args(["a", "b", "c"])
assert args["files"] == ["a", "b", "c"]
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"