From 2bdca72e0439236e940a3cc4f4de5b80a37cdc9a Mon Sep 17 00:00:00 2001 From: Roland Thomas Date: Tue, 13 May 2025 20:07:31 -0400 Subject: [PATCH] Create action submodule, add various examples --- examples/action_factory_demo.py | 48 +++++++ examples/config_loading.py | 10 ++ examples/falyx.yaml | 22 +++ examples/falyx_demo.py | 171 +++++++++++++++++++++++ examples/file_select.py | 26 ++++ examples/http_demo.py | 67 +++++++++ examples/menu_demo.py | 113 +++++++++++++++ examples/pipeline_demo.py | 78 +++++++++++ examples/process_pool.py | 2 +- examples/selection_demo.py | 22 +++ examples/shell_example.py | 90 ++++++++++++ examples/submenu.py | 52 +++++++ falyx/__init__.py | 2 +- falyx/action/__init__.py | 41 ++++++ falyx/{ => action}/action.py | 2 +- falyx/{ => action}/action_factory.py | 4 +- falyx/{ => action}/http_action.py | 4 +- falyx/{ => action}/io_action.py | 4 +- falyx/{ => action}/menu_action.py | 84 +---------- falyx/{ => action}/select_file_action.py | 40 +----- falyx/{ => action}/selection_action.py | 4 +- falyx/action/signal_action.py | 43 ++++++ falyx/action/types.py | 37 +++++ falyx/bottom_bar.py | 2 +- falyx/command.py | 6 +- falyx/config.py | 4 +- falyx/execution_registry.py | 2 +- falyx/falyx.py | 4 +- falyx/hooks.py | 2 +- falyx/menu.py | 85 +++++++++++ falyx/prompt_utils.py | 2 +- falyx/protocols.py | 2 +- falyx/retry_utils.py | 2 +- falyx/selection.py | 2 +- falyx/signal_action.py | 31 ---- falyx/themes/__init__.py | 15 ++ falyx/version.py | 2 +- pyproject.toml | 3 +- tests/test_command.py | 3 +- 39 files changed, 956 insertions(+), 177 deletions(-) create mode 100644 examples/action_factory_demo.py create mode 100644 examples/config_loading.py create mode 100644 examples/falyx.yaml create mode 100644 examples/falyx_demo.py create mode 100644 examples/file_select.py create mode 100644 examples/http_demo.py create mode 100644 examples/menu_demo.py create mode 100644 examples/pipeline_demo.py create mode 100644 examples/selection_demo.py create mode 100755 examples/shell_example.py create mode 100644 examples/submenu.py create mode 100644 falyx/action/__init__.py rename falyx/{ => action}/action.py (99%) rename falyx/{ => action}/action_factory.py (98%) rename falyx/{ => action}/http_action.py (98%) rename falyx/{ => action}/io_action.py (99%) rename falyx/{ => action}/menu_action.py (65%) rename falyx/{ => action}/select_file_action.py (88%) rename falyx/{ => action}/selection_action.py (99%) create mode 100644 falyx/action/signal_action.py create mode 100644 falyx/action/types.py create mode 100644 falyx/menu.py delete mode 100644 falyx/signal_action.py create mode 100644 falyx/themes/__init__.py diff --git a/examples/action_factory_demo.py b/examples/action_factory_demo.py new file mode 100644 index 0000000..f55cfcd --- /dev/null +++ b/examples/action_factory_demo.py @@ -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()) diff --git a/examples/config_loading.py b/examples/config_loading.py new file mode 100644 index 0000000..f2714f4 --- /dev/null +++ b/examples/config_loading.py @@ -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()) diff --git a/examples/falyx.yaml b/examples/falyx.yaml new file mode 100644 index 0000000..579056e --- /dev/null +++ b/examples/falyx.yaml @@ -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. diff --git a/examples/falyx_demo.py b/examples/falyx_demo.py new file mode 100644 index 0000000..fc33a35 --- /dev/null +++ b/examples/falyx_demo.py @@ -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 diff --git a/examples/file_select.py b/examples/file_select.py new file mode 100644 index 0000000..00fc42c --- /dev/null +++ b/examples/file_select.py @@ -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()) diff --git a/examples/http_demo.py b/examples/http_demo.py new file mode 100644 index 0000000..43cc0ee --- /dev/null +++ b/examples/http_demo.py @@ -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()) diff --git a/examples/menu_demo.py b/examples/menu_demo.py new file mode 100644 index 0000000..a9d7a0a --- /dev/null +++ b/examples/menu_demo.py @@ -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()) diff --git a/examples/pipeline_demo.py b/examples/pipeline_demo.py new file mode 100644 index 0000000..eaa64ee --- /dev/null +++ b/examples/pipeline_demo.py @@ -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()) diff --git a/examples/process_pool.py b/examples/process_pool.py index dadf71c..cebc6d4 100644 --- a/examples/process_pool.py +++ b/examples/process_pool.py @@ -1,7 +1,7 @@ from rich.console import Console from falyx import Falyx, ProcessAction -from falyx.themes.colors import NordColors as nc +from falyx.themes import NordColors as nc console = Console() falyx = Falyx(title="๐Ÿš€ Process Pool Demo") diff --git a/examples/selection_demo.py b/examples/selection_demo.py new file mode 100644 index 0000000..ed9ac55 --- /dev/null +++ b/examples/selection_demo.py @@ -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() diff --git a/examples/shell_example.py b/examples/shell_example.py new file mode 100755 index 0000000..69ca40b --- /dev/null +++ b/examples/shell_example.py @@ -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()) diff --git a/examples/submenu.py b/examples/submenu.py new file mode 100644 index 0000000..43e9367 --- /dev/null +++ b/examples/submenu.py @@ -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()) diff --git a/falyx/__init__.py b/falyx/__init__.py index 8c0c713..669570b 100644 --- a/falyx/__init__.py +++ b/falyx/__init__.py @@ -7,7 +7,7 @@ Licensed under the MIT License. See LICENSE file for details. import logging -from .action import Action, ActionGroup, ChainedAction, ProcessAction +from .action.action import Action, ActionGroup, ChainedAction, ProcessAction from .command import Command from .context import ExecutionContext, SharedContext from .execution_registry import ExecutionRegistry diff --git a/falyx/action/__init__.py b/falyx/action/__init__.py new file mode 100644 index 0000000..6f62c54 --- /dev/null +++ b/falyx/action/__init__.py @@ -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", +] diff --git a/falyx/action.py b/falyx/action/action.py similarity index 99% rename from falyx/action.py rename to falyx/action/action.py index bb5e61b..489838e 100644 --- a/falyx/action.py +++ b/falyx/action/action.py @@ -48,7 +48,7 @@ from falyx.hook_manager import Hook, HookManager, HookType from falyx.logger import logger from falyx.options_manager import OptionsManager from falyx.retry import RetryHandler, RetryPolicy -from falyx.themes.colors import OneColors +from falyx.themes import OneColors from falyx.utils import ensure_async diff --git a/falyx/action_factory.py b/falyx/action/action_factory.py similarity index 98% rename from falyx/action_factory.py rename to falyx/action/action_factory.py index 16d5b00..8232d9e 100644 --- a/falyx/action_factory.py +++ b/falyx/action/action_factory.py @@ -4,13 +4,13 @@ from typing import Any from rich.tree import Tree -from falyx.action import BaseAction +from falyx.action.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.protocols import ActionFactoryProtocol -from falyx.themes.colors import OneColors +from falyx.themes import OneColors class ActionFactoryAction(BaseAction): diff --git a/falyx/http_action.py b/falyx/action/http_action.py similarity index 98% rename from falyx/http_action.py rename to falyx/action/http_action.py index a662f28..b310a76 100644 --- a/falyx/http_action.py +++ b/falyx/action/http_action.py @@ -13,11 +13,11 @@ from typing import Any import aiohttp from rich.tree import Tree -from falyx.action import Action +from falyx.action.action import Action from falyx.context import ExecutionContext, SharedContext from falyx.hook_manager import HookManager, HookType 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: diff --git a/falyx/io_action.py b/falyx/action/io_action.py similarity index 99% rename from falyx/io_action.py rename to falyx/action/io_action.py index 3f99578..dee53ba 100644 --- a/falyx/io_action.py +++ b/falyx/action/io_action.py @@ -23,13 +23,13 @@ from typing import Any from rich.tree import Tree -from falyx.action import BaseAction +from falyx.action.action import BaseAction from falyx.context import ExecutionContext from falyx.exceptions import FalyxError from falyx.execution_registry import ExecutionRegistry as er from falyx.hook_manager import HookManager, HookType from falyx.logger import logger -from falyx.themes.colors import OneColors +from falyx.themes import OneColors class BaseIOAction(BaseAction): diff --git a/falyx/menu_action.py b/falyx/action/menu_action.py similarity index 65% rename from falyx/menu_action.py rename to falyx/action/menu_action.py index b79dcd9..b1f47a6 100644 --- a/falyx/menu_action.py +++ b/falyx/action/menu_action.py @@ -1,6 +1,5 @@ # Falyx CLI Framework โ€” (c) 2025 rtj.dev LLC โ€” MIT Licensed """menu_action.py""" -from dataclasses import dataclass from typing import Any from prompt_toolkit import PromptSession @@ -8,91 +7,16 @@ from rich.console import Console from rich.table import Table from rich.tree import Tree -from falyx.action import BaseAction +from falyx.action.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.menu import MenuOptionMap from falyx.selection import prompt_for_selection, render_table_base -from falyx.signal_action import SignalAction from falyx.signals import BackSignal, QuitSignal -from falyx.themes.colors import OneColors -from falyx.utils import CaseInsensitiveDict, 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 +from falyx.themes import OneColors +from falyx.utils import chunks class MenuAction(BaseAction): diff --git a/falyx/select_file_action.py b/falyx/action/select_file_action.py similarity index 88% rename from falyx/select_file_action.py rename to falyx/action/select_file_action.py index 57f3d79..ea560bf 100644 --- a/falyx/select_file_action.py +++ b/falyx/action/select_file_action.py @@ -5,7 +5,6 @@ from __future__ import annotations import csv import json import xml.etree.ElementTree as ET -from enum import Enum from pathlib import Path from typing import Any @@ -15,7 +14,8 @@ from prompt_toolkit import PromptSession from rich.console import Console 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.execution_registry import ExecutionRegistry as er from falyx.hook_manager import HookType @@ -25,41 +25,7 @@ from falyx.selection import ( prompt_for_selection, render_selection_dict_table, ) -from falyx.themes.colors 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}") +from falyx.themes import OneColors class SelectFileAction(BaseAction): diff --git a/falyx/selection_action.py b/falyx/action/selection_action.py similarity index 99% rename from falyx/selection_action.py rename to falyx/action/selection_action.py index 174f35e..ead89a3 100644 --- a/falyx/selection_action.py +++ b/falyx/action/selection_action.py @@ -6,7 +6,7 @@ from prompt_toolkit import PromptSession from rich.console import Console from rich.tree import Tree -from falyx.action import BaseAction +from falyx.action.action import BaseAction from falyx.context import ExecutionContext from falyx.execution_registry import ExecutionRegistry as er from falyx.hook_manager import HookType @@ -18,7 +18,7 @@ from falyx.selection import ( render_selection_dict_table, render_selection_indexed_table, ) -from falyx.themes.colors import OneColors +from falyx.themes import OneColors from falyx.utils import CaseInsensitiveDict diff --git a/falyx/action/signal_action.py b/falyx/action/signal_action.py new file mode 100644 index 0000000..c07d291 --- /dev/null +++ b/falyx/action/signal_action.py @@ -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) diff --git a/falyx/action/types.py b/falyx/action/types.py new file mode 100644 index 0000000..344f430 --- /dev/null +++ b/falyx/action/types.py @@ -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}") diff --git a/falyx/bottom_bar.py b/falyx/bottom_bar.py index d4e60df..e5c1535 100644 --- a/falyx/bottom_bar.py +++ b/falyx/bottom_bar.py @@ -8,7 +8,7 @@ from prompt_toolkit.key_binding import KeyBindings from rich.console import Console from falyx.options_manager import OptionsManager -from falyx.themes.colors import OneColors +from falyx.themes import OneColors from falyx.utils import CaseInsensitiveDict, chunks diff --git a/falyx/command.py b/falyx/command.py index 2ac0d73..0933d57 100644 --- a/falyx/command.py +++ b/falyx/command.py @@ -26,19 +26,19 @@ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator from rich.console import Console 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.debug import register_debug_hooks from falyx.exceptions import FalyxError from falyx.execution_registry import ExecutionRegistry as er from falyx.hook_manager import HookManager, HookType -from falyx.io_action import BaseIOAction from falyx.logger import logger from falyx.options_manager import OptionsManager from falyx.prompt_utils import confirm_async, should_prompt_user from falyx.retry import RetryPolicy 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 console = Console(color_system="auto") diff --git a/falyx/config.py b/falyx/config.py index 7ad72d7..48bc775 100644 --- a/falyx/config.py +++ b/falyx/config.py @@ -13,12 +13,12 @@ import yaml from pydantic import BaseModel, Field, field_validator, model_validator 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.falyx import Falyx from falyx.logger import logger from falyx.retry import RetryPolicy -from falyx.themes.colors import OneColors +from falyx.themes import OneColors console = Console(color_system="auto") diff --git a/falyx/execution_registry.py b/falyx/execution_registry.py index 36fa50f..b5d0a7b 100644 --- a/falyx/execution_registry.py +++ b/falyx/execution_registry.py @@ -37,7 +37,7 @@ from rich.table import Table from falyx.context import ExecutionContext from falyx.logger import logger -from falyx.themes.colors import OneColors +from falyx.themes import OneColors class ExecutionRegistry: diff --git a/falyx/falyx.py b/falyx/falyx.py index a9982db..44fe3d4 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -38,7 +38,7 @@ from rich.console import Console from rich.markdown import Markdown 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.command import Command from falyx.context import ExecutionContext @@ -56,7 +56,7 @@ from falyx.options_manager import OptionsManager from falyx.parsers import get_arg_parsers from falyx.retry import RetryPolicy 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.version import __version__ diff --git a/falyx/hooks.py b/falyx/hooks.py index bbe2535..dbd4435 100644 --- a/falyx/hooks.py +++ b/falyx/hooks.py @@ -6,7 +6,7 @@ from typing import Any, Callable from falyx.context import ExecutionContext from falyx.exceptions import CircuitBreakerOpen from falyx.logger import logger -from falyx.themes.colors import OneColors +from falyx.themes import OneColors class ResultReporter: diff --git a/falyx/menu.py b/falyx/menu.py new file mode 100644 index 0000000..8017101 --- /dev/null +++ b/falyx/menu.py @@ -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 diff --git a/falyx/prompt_utils.py b/falyx/prompt_utils.py index be5d962..9a91cda 100644 --- a/falyx/prompt_utils.py +++ b/falyx/prompt_utils.py @@ -8,7 +8,7 @@ from prompt_toolkit.formatted_text import ( ) from falyx.options_manager import OptionsManager -from falyx.themes.colors import OneColors +from falyx.themes import OneColors from falyx.validators import yes_no_validator diff --git a/falyx/protocols.py b/falyx/protocols.py index 288613b..a9b1cc5 100644 --- a/falyx/protocols.py +++ b/falyx/protocols.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any, Protocol -from falyx.action import BaseAction +from falyx.action.action import BaseAction class ActionFactoryProtocol(Protocol): diff --git a/falyx/retry_utils.py b/falyx/retry_utils.py index 51c7e73..9003b0b 100644 --- a/falyx/retry_utils.py +++ b/falyx/retry_utils.py @@ -1,6 +1,6 @@ # Falyx CLI Framework โ€” (c) 2025 rtj.dev LLC โ€” MIT Licensed """retry_utils.py""" -from falyx.action import Action, BaseAction +from falyx.action.action import Action, BaseAction from falyx.hook_manager import HookType from falyx.retry import RetryHandler, RetryPolicy diff --git a/falyx/selection.py b/falyx/selection.py index de5ac27..bc1e14e 100644 --- a/falyx/selection.py +++ b/falyx/selection.py @@ -9,7 +9,7 @@ from rich.console import Console from rich.markup import escape from rich.table import Table -from falyx.themes.colors import OneColors +from falyx.themes import OneColors from falyx.utils import chunks from falyx.validators import int_range_validator, key_validator diff --git a/falyx/signal_action.py b/falyx/signal_action.py deleted file mode 100644 index 5c4bbff..0000000 --- a/falyx/signal_action.py +++ /dev/null @@ -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__})" diff --git a/falyx/themes/__init__.py b/falyx/themes/__init__.py new file mode 100644 index 0000000..651f175 --- /dev/null +++ b/falyx/themes/__init__.py @@ -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", +] diff --git a/falyx/version.py b/falyx/version.py index e8438af..43a0e4e 100644 --- a/falyx/version.py +++ b/falyx/version.py @@ -1 +1 @@ -__version__ = "0.1.24" +__version__ = "0.1.25" diff --git a/pyproject.toml b/pyproject.toml index 1056ca6..904291f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "falyx" -version = "0.1.24" +version = "0.1.25" description = "Reliable and introspectable async CLI action framework." authors = ["Roland Thomas Jr "] license = "MIT" @@ -15,6 +15,7 @@ pydantic = "^2.0" python-json-logger = "^3.3.0" toml = "^0.10" pyyaml = "^6.0" +aiohttp = "^3.11" [tool.poetry.group.dev.dependencies] pytest = "^8.3.5" diff --git a/tests/test_command.py b/tests/test_command.py index 66947b6..21891eb 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -1,10 +1,9 @@ # test_command.py import pytest -from falyx.action import Action, ActionGroup, ChainedAction +from falyx.action import Action, ActionGroup, BaseIOAction, ChainedAction from falyx.command import Command from falyx.execution_registry import ExecutionRegistry as er -from falyx.io_action import BaseIOAction from falyx.retry import RetryPolicy asyncio_default_fixture_loop_scope = "function"