Create action submodule, add various examples

This commit is contained in:
Roland Thomas Jr 2025-05-13 20:07:31 -04:00
parent 87a56ac40b
commit 2bdca72e04
Signed by: roland
GPG Key ID: 7C3C2B085A4C2872
39 changed files with 956 additions and 177 deletions

View File

@ -0,0 +1,48 @@
import asyncio
from falyx import Falyx
from falyx.action import ActionFactoryAction, ChainedAction, HTTPAction, SelectionAction
# Selection of a post ID to fetch (just an example set)
post_selector = SelectionAction(
name="Pick Post ID",
selections=["1", "2", "3", "4", "5"],
title="Choose a Post ID to submit",
prompt_message="Post ID > ",
show_table=True,
)
# Factory that builds and executes the actual HTTP POST request
def build_post_action(post_id) -> HTTPAction:
print(f"Building HTTPAction for Post ID: {post_id}")
return HTTPAction(
name=f"POST to /posts (id={post_id})",
method="POST",
url="https://jsonplaceholder.typicode.com/posts",
json={"title": "foo", "body": "bar", "userId": int(post_id)},
)
post_factory = ActionFactoryAction(
name="Build HTTPAction from Post ID",
factory=build_post_action,
inject_last_result=True,
inject_into="post_id",
preview_kwargs={"post_id": "100"},
)
# Wrap in a ChainedAction
chain = ChainedAction(
name="Submit Post Flow",
actions=[post_selector, post_factory],
auto_inject=True,
)
flx = Falyx()
flx.add_command(
key="S",
description="Submit a Post",
action=chain,
)
asyncio.run(flx.run())

View File

@ -0,0 +1,10 @@
"""config_loading.py"""
from falyx.config import loader
flx = loader("falyx.yaml")
if __name__ == "__main__":
import asyncio
asyncio.run(flx.run())

22
examples/falyx.yaml Normal file
View File

@ -0,0 +1,22 @@
commands:
- key: P
description: Pipeline Demo
action: pipeline_demo.pipeline
tags: [pipeline, demo]
help_text: Run Demployment Pipeline with retries.
- key: G
description: Run HTTP Action Group
action: http_demo.action_group
tags: [http, demo]
- key: S
description: Select a file
action: file_select.sf
tags: [file, select, demo]
- key: M
description: Menu Demo
action: menu_demo.menu
tags: [menu, demo]
help_text: Run a menu demo with multiple options.

171
examples/falyx_demo.py Normal file
View File

@ -0,0 +1,171 @@
"""
Falyx CLI Framework
Copyright (c) 2025 rtj.dev LLC.
Licensed under the MIT License. See LICENSE file for details.
"""
import asyncio
import random
from argparse import Namespace
from falyx.action import Action, ActionGroup, ChainedAction
from falyx.falyx import Falyx
from falyx.parsers import FalyxParsers, get_arg_parsers
from falyx.version import __version__
class Foo:
def __init__(self, flx: Falyx) -> None:
self.flx = flx
async def build(self):
await asyncio.sleep(1)
print("✅ Build complete!")
return "Build complete!"
async def test(self):
await asyncio.sleep(1)
print("✅ Tests passed!")
return "Tests passed!"
async def deploy(self):
await asyncio.sleep(1)
print("✅ Deployment complete!")
return "Deployment complete!"
async def clean(self):
print("🧹 Cleaning...")
await asyncio.sleep(1)
print("✅ Clean complete!")
return "Clean complete!"
async def build_package(self):
print("🔨 Building...")
await asyncio.sleep(1)
print("✅ Build finished!")
return "Build finished!"
async def package(self):
print("📦 Packaging...")
await asyncio.sleep(1)
print("✅ Package complete!")
return "Package complete!"
async def run_tests(self):
print("🧪 Running tests...")
await asyncio.sleep(random.randint(1, 3))
print("✅ Tests passed!")
return "Tests passed!"
async def run_integration_tests(self):
print("🔗 Running integration tests...")
await asyncio.sleep(random.randint(1, 3))
print("✅ Integration tests passed!")
return "Integration tests passed!"
async def run_linter(self):
print("🧹 Running linter...")
await asyncio.sleep(random.randint(1, 3))
print("✅ Linter passed!")
return "Linter passed!"
async def run(self):
await self.flx.run()
def parse_args() -> Namespace:
parsers: FalyxParsers = get_arg_parsers()
return parsers.parse_args()
async def main() -> None:
"""Build and return a Falyx instance with all your commands."""
args = parse_args()
flx = Falyx(
title="🚀 Falyx CLI",
cli_args=args,
columns=5,
welcome_message="Welcome to Falyx CLI!",
exit_message="Goodbye!",
)
foo = Foo(flx)
# --- Bottom bar info ---
flx.bottom_bar.columns = 3
flx.bottom_bar.add_toggle_from_option("V", "Verbose", flx.options, "verbose")
flx.bottom_bar.add_toggle_from_option("U", "Debug Hooks", flx.options, "debug_hooks")
flx.bottom_bar.add_static("Version", f"Falyx v{__version__}")
# --- Command actions ---
# --- Single Actions ---
flx.add_command(
key="B",
description="Build project",
action=Action("Build", foo.build),
tags=["build"],
spinner=True,
spinner_message="📦 Building...",
)
flx.add_command(
key="T",
description="Run tests",
action=Action("Test", foo.test),
tags=["test"],
spinner=True,
spinner_message="🧪 Running tests...",
)
flx.add_command(
key="D",
description="Deploy project",
action=Action("Deploy", foo.deploy),
tags=["deploy"],
spinner=True,
spinner_message="🚀 Deploying...",
)
# --- Build pipeline (ChainedAction) ---
pipeline = ChainedAction(
name="Full Build Pipeline",
actions=[
Action("Clean", foo.clean),
Action("Build", foo.build_package),
Action("Package", foo.package),
],
)
flx.add_command(
key="P",
description="Run Build Pipeline",
action=pipeline,
tags=["build", "pipeline"],
spinner=True,
spinner_message="🔨 Running build pipeline...",
spinner_type="line",
)
# --- Test suite (ActionGroup) ---
test_suite = ActionGroup(
name="Test Suite",
actions=[
Action("Unit Tests", foo.run_tests),
Action("Integration Tests", foo.run_integration_tests),
Action("Lint", foo.run_linter),
],
)
flx.add_command(
key="G",
description="Run All Tests",
action=test_suite,
tags=["test", "parallel"],
spinner=True,
spinner_type="line",
)
await foo.run()
if __name__ == "__main__":
try:
asyncio.run(main())
except (KeyboardInterrupt, EOFError):
pass

26
examples/file_select.py Normal file
View File

@ -0,0 +1,26 @@
import asyncio
from falyx import Falyx
from falyx.action import SelectFileAction
from falyx.action.types import FileReturnType
sf = SelectFileAction(
name="select_file",
suffix_filter=".py",
title="Select a YAML file",
prompt_message="Choose > ",
return_type=FileReturnType.TEXT,
columns=3,
)
flx = Falyx()
flx.add_command(
key="S",
description="Select a file",
action=sf,
help_text="Select a file from the current directory",
)
if __name__ == "__main__":
asyncio.run(flx.run())

67
examples/http_demo.py Normal file
View File

@ -0,0 +1,67 @@
import asyncio
from rich.console import Console
from falyx import ActionGroup, Falyx
from falyx.action import HTTPAction
from falyx.hook_manager import HookType
from falyx.hooks import ResultReporter
console = Console()
action_group = ActionGroup(
"HTTP Group",
actions=[
HTTPAction(
name="Get Example",
method="GET",
url="https://jsonplaceholder.typicode.com/posts/1",
headers={"Accept": "application/json"},
retry=True,
),
HTTPAction(
name="Post Example",
method="POST",
url="https://jsonplaceholder.typicode.com/posts",
headers={"Content-Type": "application/json"},
json={"title": "foo", "body": "bar", "userId": 1},
retry=True,
),
HTTPAction(
name="Put Example",
method="PUT",
url="https://jsonplaceholder.typicode.com/posts/1",
headers={"Content-Type": "application/json"},
json={"id": 1, "title": "foo", "body": "bar", "userId": 1},
retry=True,
),
HTTPAction(
name="Delete Example",
method="DELETE",
url="https://jsonplaceholder.typicode.com/posts/1",
headers={"Content-Type": "application/json"},
retry=True,
),
],
)
reporter = ResultReporter()
action_group.hooks.register(
HookType.ON_SUCCESS,
reporter.report,
)
flx = Falyx("HTTP Demo")
flx.add_command(
key="G",
description="Run HTTP Action Group",
action=action_group,
spinner=True,
)
if __name__ == "__main__":
asyncio.run(flx.run())

113
examples/menu_demo.py Normal file
View File

@ -0,0 +1,113 @@
import asyncio
import time
from falyx import Falyx
from falyx.action import Action, ActionGroup, ChainedAction, MenuAction, ProcessAction
from falyx.menu import MenuOption, MenuOptionMap
# Basic coroutine for Action
async def greet_user():
print("👋 Hello from a regular Action!")
await asyncio.sleep(0.5)
return "Greeted user."
# Chain of tasks
async def fetch_data():
print("📡 Fetching data...")
await asyncio.sleep(1)
return "data123"
async def process_data(last_result):
print(f"⚙️ Processing: {last_result}")
await asyncio.sleep(1)
return f"processed({last_result})"
async def save_data(last_result):
print(f"💾 Saving: {last_result}")
await asyncio.sleep(1)
return f"saved({last_result})"
# Parallel tasks
async def fetch_users():
print("👥 Fetching users...")
await asyncio.sleep(1)
return ["alice", "bob", "carol"]
async def fetch_logs():
print("📝 Fetching logs...")
await asyncio.sleep(2)
return ["log1", "log2"]
# CPU-bound task (simulate via blocking sleep)
def heavy_computation():
print("🧠 Starting heavy computation...")
time.sleep(3)
print("✅ Finished computation.")
return 42
# Define actions
basic_action = Action("greet", greet_user)
chained = ChainedAction(
name="data-pipeline",
actions=[
Action("fetch", fetch_data),
Action("process", process_data, inject_last_result=True),
Action("save", save_data, inject_last_result=True),
],
auto_inject=True,
)
parallel = ActionGroup(
name="parallel-fetch",
actions=[
Action("fetch-users", fetch_users),
Action("fetch-logs", fetch_logs),
],
)
process = ProcessAction(name="compute", action=heavy_computation)
# Menu setup
menu = MenuAction(
name="main-menu",
title="Choose a task to run",
menu_options=MenuOptionMap(
{
"1": MenuOption("Run basic Action", basic_action),
"2": MenuOption("Run ChainedAction", chained),
"3": MenuOption("Run ActionGroup (parallel)", parallel),
"4": MenuOption("Run ProcessAction (heavy task)", process),
}
),
)
flx = Falyx(
title="🚀 Falyx Menu Demo",
welcome_message="Welcome to the Menu Demo!",
exit_message="Goodbye!",
columns=2,
never_prompt=False,
)
flx.add_command(
key="M",
description="Show Menu",
action=menu,
logging_hooks=True,
)
if __name__ == "__main__":
asyncio.run(flx.run())

78
examples/pipeline_demo.py Normal file
View File

@ -0,0 +1,78 @@
import asyncio
from falyx import Action, ActionGroup, ChainedAction
from falyx import ExecutionRegistry as er
from falyx import ProcessAction
from falyx.hook_manager import HookType
from falyx.retry import RetryHandler, RetryPolicy
# Step 1: Fast I/O-bound setup (standard Action)
async def checkout_code():
print("📥 Checking out code...")
await asyncio.sleep(0.5)
# Step 2: CPU-bound task (ProcessAction)
def run_static_analysis():
print("🧠 Running static analysis (CPU-bound)...")
total = 0
for i in range(10_000_000):
total += i % 3
return total
# Step 3: Simulated flaky test with retry
async def flaky_tests():
import random
await asyncio.sleep(0.3)
if random.random() < 0.3:
raise RuntimeError("❌ Random test failure!")
print("🧪 Tests passed.")
return "ok"
# Step 4: Multiple deploy targets (parallel ActionGroup)
async def deploy_to(target: str):
print(f"🚀 Deploying to {target}...")
await asyncio.sleep(0.2)
return f"{target} complete"
def build_pipeline():
retry_handler = RetryHandler(RetryPolicy(max_retries=3, delay=0.5))
# Base actions
checkout = Action("Checkout", checkout_code)
analysis = ProcessAction("Static Analysis", run_static_analysis)
tests = Action("Run Tests", flaky_tests)
tests.hooks.register(HookType.ON_ERROR, retry_handler.retry_on_error)
# Parallel deploys
deploy_group = ActionGroup(
"Deploy to All",
[
Action("Deploy US", deploy_to, args=("us-west",)),
Action("Deploy EU", deploy_to, args=("eu-central",)),
Action("Deploy Asia", deploy_to, args=("asia-east",)),
],
)
# Full pipeline
return ChainedAction("CI/CD Pipeline", [checkout, analysis, tests, deploy_group])
pipeline = build_pipeline()
# Run the pipeline
async def main():
pipeline = build_pipeline()
await pipeline()
er.summary()
await pipeline.preview()
if __name__ == "__main__":
asyncio.run(main())

View File

@ -1,7 +1,7 @@
from rich.console import Console from rich.console import Console
from falyx import Falyx, ProcessAction from falyx import Falyx, ProcessAction
from falyx.themes.colors import NordColors as nc from falyx.themes import NordColors as nc
console = Console() console = Console()
falyx = Falyx(title="🚀 Process Pool Demo") falyx = Falyx(title="🚀 Process Pool Demo")

View File

@ -0,0 +1,22 @@
import asyncio
from falyx.selection import (
SelectionOption,
prompt_for_selection,
render_selection_dict_table,
)
menu = {
"A": SelectionOption("Run diagnostics", lambda: print("Running diagnostics...")),
"B": SelectionOption("Deploy to staging", lambda: print("Deploying...")),
}
table = render_selection_dict_table(
title="Main Menu",
selections=menu,
)
key = asyncio.run(prompt_for_selection(menu.keys(), table))
print(f"You selected: {key}")
menu[key.upper()].value()

90
examples/shell_example.py Executable file
View File

@ -0,0 +1,90 @@
#!/usr/bin/env python
import asyncio
from falyx import Action, ChainedAction, Falyx
from falyx.action import ShellAction
from falyx.hook_manager import HookType
from falyx.hooks import ResultReporter
from falyx.utils import setup_logging
# Setup logging
setup_logging()
fx = Falyx("🚀 Falyx Demo")
e = ShellAction("Shell", "echo Hello, {}!")
fx.add_command(
key="R",
description="Echo a message",
action=e,
)
s = ShellAction("Ping", "ping -c 1 {}")
fx.add_command(
key="P",
description="Ping a host",
action=s,
)
async def a1(last_result):
return f"Hello, {last_result}"
async def a2(last_result):
return f"World! {last_result}"
reporter = ResultReporter()
a1 = Action("a1", a1, inject_last_result=True)
a1.hooks.register(
HookType.ON_SUCCESS,
reporter.report,
)
a2 = Action("a2", a2, inject_last_result=True)
a2.hooks.register(
HookType.ON_SUCCESS,
reporter.report,
)
async def normal():
print("Normal")
return "Normal"
async def annotate(last_result):
return f"Annotated: {last_result}"
async def whisper(last_result):
return last_result.lower()
c1 = ChainedAction(
name="ShellDemo",
actions=[
# host,
ShellAction("Ping", "ping -c 1 {}"),
Action("Annotate", annotate),
Action("Whisper", whisper),
],
auto_inject=True,
)
fx.add_command(
key="C",
description="Run a chain of actions",
action=c1,
)
async def main():
await fx.run()
asyncio.run(main())

52
examples/submenu.py Normal file
View File

@ -0,0 +1,52 @@
import asyncio
import random
from falyx import Action, ChainedAction, Falyx
from falyx.utils import setup_logging
setup_logging()
# A flaky async step that fails randomly
async def flaky_step():
await asyncio.sleep(0.2)
if random.random() < 0.5:
raise RuntimeError("Random failure!")
return "ok"
step1 = Action(name="step_1", action=flaky_step, retry=True)
step2 = Action(name="step_2", action=flaky_step, retry=True)
# Chain the actions
chain = ChainedAction(name="my_pipeline", actions=[step1, step2])
# Create the CLI menu
falyx = Falyx("🚀 Falyx Demo")
falyx.add_command(
key="R",
description="Run My Pipeline",
action=chain,
logging_hooks=True,
preview_before_confirm=True,
confirm=True,
)
# Create a submenu
submenu = Falyx("Submenu")
submenu.add_command(
key="T",
description="Test",
action=lambda: "test",
logging_hooks=True,
preview_before_confirm=True,
confirm=True,
)
falyx.add_submenu(
key="S",
description="Submenu",
submenu=submenu,
)
if __name__ == "__main__":
asyncio.run(falyx.run())

View File

@ -7,7 +7,7 @@ Licensed under the MIT License. See LICENSE file for details.
import logging import logging
from .action import Action, ActionGroup, ChainedAction, ProcessAction from .action.action import Action, ActionGroup, ChainedAction, ProcessAction
from .command import Command from .command import Command
from .context import ExecutionContext, SharedContext from .context import ExecutionContext, SharedContext
from .execution_registry import ExecutionRegistry from .execution_registry import ExecutionRegistry

41
falyx/action/__init__.py Normal file
View File

@ -0,0 +1,41 @@
"""
Falyx CLI Framework
Copyright (c) 2025 rtj.dev LLC.
Licensed under the MIT License. See LICENSE file for details.
"""
from .action import (
Action,
ActionGroup,
BaseAction,
ChainedAction,
FallbackAction,
LiteralInputAction,
ProcessAction,
)
from .action_factory import ActionFactoryAction
from .http_action import HTTPAction
from .io_action import BaseIOAction, ShellAction
from .menu_action import MenuAction
from .select_file_action import SelectFileAction
from .selection_action import SelectionAction
from .signal_action import SignalAction
__all__ = [
"Action",
"ActionGroup",
"BaseAction",
"ChainedAction",
"ProcessAction",
"ActionFactoryAction",
"HTTPAction",
"BaseIOAction",
"ShellAction",
"SelectionAction",
"SelectFileAction",
"MenuAction",
"SignalAction",
"FallbackAction",
"LiteralInputAction",
]

View File

@ -48,7 +48,7 @@ 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.options_manager import OptionsManager
from falyx.retry import RetryHandler, RetryPolicy from falyx.retry import RetryHandler, RetryPolicy
from falyx.themes.colors import OneColors from falyx.themes import OneColors
from falyx.utils import ensure_async from falyx.utils import ensure_async

View File

@ -4,13 +4,13 @@ from typing import Any
from rich.tree import Tree from rich.tree import Tree
from falyx.action import BaseAction from falyx.action.action import BaseAction
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType from falyx.hook_manager import HookType
from falyx.logger import logger from falyx.logger import logger
from falyx.protocols import ActionFactoryProtocol from falyx.protocols import ActionFactoryProtocol
from falyx.themes.colors import OneColors from falyx.themes import OneColors
class ActionFactoryAction(BaseAction): class ActionFactoryAction(BaseAction):

View File

@ -13,11 +13,11 @@ from typing import Any
import aiohttp import aiohttp
from rich.tree import Tree from rich.tree import Tree
from falyx.action import Action from falyx.action.action import Action
from falyx.context import ExecutionContext, SharedContext from falyx.context import ExecutionContext, SharedContext
from falyx.hook_manager import HookManager, HookType from falyx.hook_manager import HookManager, HookType
from falyx.logger import logger from falyx.logger import logger
from falyx.themes.colors import OneColors from falyx.themes import OneColors
async def close_shared_http_session(context: ExecutionContext) -> None: async def close_shared_http_session(context: ExecutionContext) -> None:

View File

@ -23,13 +23,13 @@ from typing import Any
from rich.tree import Tree from rich.tree import Tree
from falyx.action import BaseAction from falyx.action.action import BaseAction
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.exceptions import FalyxError from falyx.exceptions import FalyxError
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType from falyx.hook_manager import HookManager, HookType
from falyx.logger import logger from falyx.logger import logger
from falyx.themes.colors import OneColors from falyx.themes import OneColors
class BaseIOAction(BaseAction): class BaseIOAction(BaseAction):

View File

@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""menu_action.py""" """menu_action.py"""
from dataclasses import dataclass
from typing import Any from typing import Any
from prompt_toolkit import PromptSession from prompt_toolkit import PromptSession
@ -8,91 +7,16 @@ 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
from falyx.action import BaseAction from falyx.action.action import BaseAction
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType from falyx.hook_manager import HookType
from falyx.logger import logger from falyx.logger import logger
from falyx.menu import MenuOptionMap
from falyx.selection import prompt_for_selection, render_table_base from falyx.selection import prompt_for_selection, render_table_base
from falyx.signal_action import SignalAction
from falyx.signals import BackSignal, QuitSignal from falyx.signals import BackSignal, QuitSignal
from falyx.themes.colors import OneColors from falyx.themes import OneColors
from falyx.utils import CaseInsensitiveDict, chunks from falyx.utils import chunks
@dataclass
class MenuOption:
"""Represents a single menu option with a description and an action to execute."""
description: str
action: BaseAction
style: str = OneColors.WHITE
def __post_init__(self):
if not isinstance(self.description, str):
raise TypeError("MenuOption description must be a string.")
if not isinstance(self.action, BaseAction):
raise TypeError("MenuOption action must be a BaseAction instance.")
def render(self, key: str) -> str:
"""Render the menu option for display."""
return f"[{OneColors.WHITE}][{key}][/] [{self.style}]{self.description}[/]"
class MenuOptionMap(CaseInsensitiveDict):
"""
Manages menu options including validation, reserved key protection,
and special signal entries like Quit and Back.
"""
RESERVED_KEYS = {"Q", "B"}
def __init__(
self,
options: dict[str, MenuOption] | None = None,
allow_reserved: bool = False,
):
super().__init__()
self.allow_reserved = allow_reserved
if options:
self.update(options)
self._inject_reserved_defaults()
def _inject_reserved_defaults(self):
self._add_reserved(
"Q",
MenuOption("Exit", SignalAction("Quit", QuitSignal()), OneColors.DARK_RED),
)
self._add_reserved(
"B",
MenuOption("Back", SignalAction("Back", BackSignal()), OneColors.DARK_YELLOW),
)
def _add_reserved(self, key: str, option: MenuOption) -> None:
"""Add a reserved key, bypassing validation."""
norm_key = key.upper()
super().__setitem__(norm_key, option)
def __setitem__(self, key: str, option: MenuOption) -> None:
if not isinstance(option, MenuOption):
raise TypeError(f"Value for key '{key}' must be a MenuOption.")
norm_key = key.upper()
if norm_key in self.RESERVED_KEYS and not self.allow_reserved:
raise ValueError(
f"Key '{key}' is reserved and cannot be used in MenuOptionMap."
)
super().__setitem__(norm_key, option)
def __delitem__(self, key: str) -> None:
if key.upper() in self.RESERVED_KEYS and not self.allow_reserved:
raise ValueError(f"Cannot delete reserved option '{key}'.")
super().__delitem__(key)
def items(self, include_reserved: bool = True):
for k, v in super().items():
if not include_reserved and k in self.RESERVED_KEYS:
continue
yield k, v
class MenuAction(BaseAction): class MenuAction(BaseAction):

View File

@ -5,7 +5,6 @@ from __future__ import annotations
import csv import csv
import json import json
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -15,7 +14,8 @@ from prompt_toolkit import PromptSession
from rich.console import Console from rich.console import Console
from rich.tree import Tree from rich.tree import Tree
from falyx.action import BaseAction from falyx.action.action import BaseAction
from falyx.action.types import FileReturnType
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType from falyx.hook_manager import HookType
@ -25,41 +25,7 @@ from falyx.selection import (
prompt_for_selection, prompt_for_selection,
render_selection_dict_table, render_selection_dict_table,
) )
from falyx.themes.colors import OneColors from falyx.themes import OneColors
class FileReturnType(Enum):
"""Enum for file return types."""
TEXT = "text"
PATH = "path"
JSON = "json"
TOML = "toml"
YAML = "yaml"
CSV = "csv"
TSV = "tsv"
XML = "xml"
@classmethod
def _get_alias(cls, value: str) -> str:
aliases = {
"yml": "yaml",
"txt": "text",
"file": "path",
"filepath": "path",
}
return aliases.get(value, value)
@classmethod
def _missing_(cls, value: object) -> FileReturnType:
if isinstance(value, str):
normalized = value.lower()
alias = cls._get_alias(normalized)
for member in cls:
if member.value == alias:
return member
valid = ", ".join(member.value for member in cls)
raise ValueError(f"Invalid FileReturnType: '{value}'. Must be one of: {valid}")
class SelectFileAction(BaseAction): class SelectFileAction(BaseAction):

View File

@ -6,7 +6,7 @@ from prompt_toolkit import PromptSession
from rich.console import Console from rich.console import Console
from rich.tree import Tree from rich.tree import Tree
from falyx.action import BaseAction from falyx.action.action import BaseAction
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType from falyx.hook_manager import HookType
@ -18,7 +18,7 @@ from falyx.selection import (
render_selection_dict_table, render_selection_dict_table,
render_selection_indexed_table, render_selection_indexed_table,
) )
from falyx.themes.colors import OneColors from falyx.themes import OneColors
from falyx.utils import CaseInsensitiveDict from falyx.utils import CaseInsensitiveDict

View File

@ -0,0 +1,43 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""signal_action.py"""
from rich.tree import Tree
from falyx.action.action import Action
from falyx.signals import FlowSignal
from falyx.themes import OneColors
class SignalAction(Action):
"""
An action that raises a control flow signal when executed.
Useful for exiting a menu, going back, or halting execution gracefully.
"""
def __init__(self, name: str, signal: Exception):
self.signal = signal
super().__init__(name, action=self.raise_signal)
async def raise_signal(self, *args, **kwargs):
raise self.signal
@property
def signal(self):
return self._signal
@signal.setter
def signal(self, value: FlowSignal):
if not isinstance(value, FlowSignal):
raise TypeError(
f"Signal must be an FlowSignal instance, got {type(value).__name__}"
)
self._signal = value
def __str__(self):
return f"SignalAction(name={self.name}, signal={self._signal.__class__.__name__})"
async def preview(self, parent: Tree | None = None):
label = f"[{OneColors.LIGHT_RED}]⚡ SignalAction[/] '{self.signal.__class__.__name__}'"
tree = parent.add(label) if parent else Tree(label)
if not parent:
self.console.print(tree)

37
falyx/action/types.py Normal file
View File

@ -0,0 +1,37 @@
from __future__ import annotations
from enum import Enum
class FileReturnType(Enum):
"""Enum for file return types."""
TEXT = "text"
PATH = "path"
JSON = "json"
TOML = "toml"
YAML = "yaml"
CSV = "csv"
TSV = "tsv"
XML = "xml"
@classmethod
def _get_alias(cls, value: str) -> str:
aliases = {
"yml": "yaml",
"txt": "text",
"file": "path",
"filepath": "path",
}
return aliases.get(value, value)
@classmethod
def _missing_(cls, value: object) -> FileReturnType:
if isinstance(value, str):
normalized = value.lower()
alias = cls._get_alias(normalized)
for member in cls:
if member.value == alias:
return member
valid = ", ".join(member.value for member in cls)
raise ValueError(f"Invalid FileReturnType: '{value}'. Must be one of: {valid}")

View File

@ -8,7 +8,7 @@ from prompt_toolkit.key_binding import KeyBindings
from rich.console import Console from rich.console import Console
from falyx.options_manager import OptionsManager from falyx.options_manager import OptionsManager
from falyx.themes.colors import OneColors from falyx.themes import OneColors
from falyx.utils import CaseInsensitiveDict, chunks from falyx.utils import CaseInsensitiveDict, chunks

View File

@ -26,19 +26,19 @@ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
from rich.console import Console from rich.console import Console
from rich.tree import Tree from rich.tree import Tree
from falyx.action import Action, ActionGroup, BaseAction, ChainedAction from falyx.action.action import Action, ActionGroup, BaseAction, ChainedAction
from falyx.action.io_action import BaseIOAction
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.exceptions import FalyxError from falyx.exceptions import FalyxError
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType from falyx.hook_manager import HookManager, HookType
from falyx.io_action import BaseIOAction
from falyx.logger import logger from falyx.logger import logger
from falyx.options_manager import OptionsManager from falyx.options_manager import OptionsManager
from falyx.prompt_utils import confirm_async, should_prompt_user from falyx.prompt_utils import confirm_async, should_prompt_user
from falyx.retry import RetryPolicy from falyx.retry import RetryPolicy
from falyx.retry_utils import enable_retries_recursively from falyx.retry_utils import enable_retries_recursively
from falyx.themes.colors import OneColors from falyx.themes import OneColors
from falyx.utils import _noop, ensure_async from falyx.utils import _noop, ensure_async
console = Console(color_system="auto") console = Console(color_system="auto")

View File

@ -13,12 +13,12 @@ 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 rich.console import Console
from falyx.action import Action, BaseAction from falyx.action.action import Action, BaseAction
from falyx.command import Command from falyx.command import Command
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.colors import OneColors from falyx.themes import OneColors
console = Console(color_system="auto") console = Console(color_system="auto")

View File

@ -37,7 +37,7 @@ from rich.table import Table
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.logger import logger from falyx.logger import logger
from falyx.themes.colors import OneColors from falyx.themes import OneColors
class ExecutionRegistry: class ExecutionRegistry:

View File

@ -38,7 +38,7 @@ from rich.console import Console
from rich.markdown import Markdown from rich.markdown import Markdown
from rich.table import Table from rich.table import Table
from falyx.action import Action, BaseAction from falyx.action.action import Action, 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.context import ExecutionContext from falyx.context import ExecutionContext
@ -56,7 +56,7 @@ from falyx.options_manager import OptionsManager
from falyx.parsers import get_arg_parsers from falyx.parsers import get_arg_parsers
from falyx.retry import RetryPolicy from falyx.retry import RetryPolicy
from falyx.signals import BackSignal, QuitSignal from falyx.signals import BackSignal, QuitSignal
from falyx.themes.colors import OneColors, get_nord_theme from falyx.themes import OneColors, get_nord_theme
from falyx.utils import CaseInsensitiveDict, chunks, get_program_invocation from falyx.utils import CaseInsensitiveDict, chunks, get_program_invocation
from falyx.version import __version__ from falyx.version import __version__

View File

@ -6,7 +6,7 @@ from typing import Any, Callable
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.exceptions import CircuitBreakerOpen from falyx.exceptions import CircuitBreakerOpen
from falyx.logger import logger from falyx.logger import logger
from falyx.themes.colors import OneColors from falyx.themes import OneColors
class ResultReporter: class ResultReporter:

85
falyx/menu.py Normal file
View File

@ -0,0 +1,85 @@
from __future__ import annotations
from dataclasses import dataclass
from falyx.action import BaseAction
from falyx.signals import BackSignal, QuitSignal
from falyx.themes import OneColors
from falyx.utils import CaseInsensitiveDict
@dataclass
class MenuOption:
"""Represents a single menu option with a description and an action to execute."""
description: str
action: BaseAction
style: str = OneColors.WHITE
def __post_init__(self):
if not isinstance(self.description, str):
raise TypeError("MenuOption description must be a string.")
if not isinstance(self.action, BaseAction):
raise TypeError("MenuOption action must be a BaseAction instance.")
def render(self, key: str) -> str:
"""Render the menu option for display."""
return f"[{OneColors.WHITE}][{key}][/] [{self.style}]{self.description}[/]"
class MenuOptionMap(CaseInsensitiveDict):
"""
Manages menu options including validation, reserved key protection,
and special signal entries like Quit and Back.
"""
RESERVED_KEYS = {"Q", "B"}
def __init__(
self,
options: dict[str, MenuOption] | None = None,
allow_reserved: bool = False,
):
super().__init__()
self.allow_reserved = allow_reserved
if options:
self.update(options)
self._inject_reserved_defaults()
def _inject_reserved_defaults(self):
from falyx.action import SignalAction
self._add_reserved(
"Q",
MenuOption("Exit", SignalAction("Quit", QuitSignal()), OneColors.DARK_RED),
)
self._add_reserved(
"B",
MenuOption("Back", SignalAction("Back", BackSignal()), OneColors.DARK_YELLOW),
)
def _add_reserved(self, key: str, option: MenuOption) -> None:
"""Add a reserved key, bypassing validation."""
norm_key = key.upper()
super().__setitem__(norm_key, option)
def __setitem__(self, key: str, option: MenuOption) -> None:
if not isinstance(option, MenuOption):
raise TypeError(f"Value for key '{key}' must be a MenuOption.")
norm_key = key.upper()
if norm_key in self.RESERVED_KEYS and not self.allow_reserved:
raise ValueError(
f"Key '{key}' is reserved and cannot be used in MenuOptionMap."
)
super().__setitem__(norm_key, option)
def __delitem__(self, key: str) -> None:
if key.upper() in self.RESERVED_KEYS and not self.allow_reserved:
raise ValueError(f"Cannot delete reserved option '{key}'.")
super().__delitem__(key)
def items(self, include_reserved: bool = True):
for k, v in super().items():
if not include_reserved and k in self.RESERVED_KEYS:
continue
yield k, v

View File

@ -8,7 +8,7 @@ from prompt_toolkit.formatted_text import (
) )
from falyx.options_manager import OptionsManager from falyx.options_manager import OptionsManager
from falyx.themes.colors import OneColors from falyx.themes import OneColors
from falyx.validators import yes_no_validator from falyx.validators import yes_no_validator

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any, Protocol from typing import Any, Protocol
from falyx.action import BaseAction from falyx.action.action import BaseAction
class ActionFactoryProtocol(Protocol): class ActionFactoryProtocol(Protocol):

View File

@ -1,6 +1,6 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""retry_utils.py""" """retry_utils.py"""
from falyx.action import Action, BaseAction from falyx.action.action import Action, BaseAction
from falyx.hook_manager import HookType from falyx.hook_manager import HookType
from falyx.retry import RetryHandler, RetryPolicy from falyx.retry import RetryHandler, RetryPolicy

View File

@ -9,7 +9,7 @@ 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.themes.colors import OneColors from falyx.themes import OneColors
from falyx.utils import chunks from falyx.utils import chunks
from falyx.validators import int_range_validator, key_validator from falyx.validators import int_range_validator, key_validator

View File

@ -1,31 +0,0 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""signal_action.py"""
from falyx.action import Action
from falyx.signals import FlowSignal
class SignalAction(Action):
"""
An action that raises a control flow signal when executed.
Useful for exiting a menu, going back, or halting execution gracefully.
"""
def __init__(self, name: str, signal: Exception):
if not isinstance(signal, FlowSignal):
raise TypeError(
f"Signal must be an FlowSignal instance, got {type(signal).__name__}"
)
async def raise_signal(*args, **kwargs):
raise signal
super().__init__(name=name, action=raise_signal)
self._signal = signal
@property
def signal(self):
return self._signal
def __str__(self):
return f"SignalAction(name={self.name}, signal={self._signal.__class__.__name__})"

15
falyx/themes/__init__.py Normal file
View File

@ -0,0 +1,15 @@
"""
Falyx CLI Framework
Copyright (c) 2025 rtj.dev LLC.
Licensed under the MIT License. See LICENSE file for details.
"""
from .colors import ColorsMeta, NordColors, OneColors, get_nord_theme
__all__ = [
"OneColors",
"NordColors",
"get_nord_theme",
"ColorsMeta",
]

View File

@ -1 +1 @@
__version__ = "0.1.24" __version__ = "0.1.25"

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "falyx" name = "falyx"
version = "0.1.24" version = "0.1.25"
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"
@ -15,6 +15,7 @@ pydantic = "^2.0"
python-json-logger = "^3.3.0" python-json-logger = "^3.3.0"
toml = "^0.10" toml = "^0.10"
pyyaml = "^6.0" pyyaml = "^6.0"
aiohttp = "^3.11"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
pytest = "^8.3.5" pytest = "^8.3.5"

View File

@ -1,10 +1,9 @@
# test_command.py # test_command.py
import pytest import pytest
from falyx.action import Action, ActionGroup, ChainedAction from falyx.action import Action, ActionGroup, BaseIOAction, ChainedAction
from falyx.command import Command from falyx.command import Command
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.io_action import BaseIOAction
from falyx.retry import RetryPolicy from falyx.retry import RetryPolicy
asyncio_default_fixture_loop_scope = "function" asyncio_default_fixture_loop_scope = "function"