Compare commits

..

59 Commits
pipes ... main

Author SHA1 Message Date
Roland Thomas Jr b24079ea7e
Add ExecutionContext.signature, fix partial command matching with arguments, fix passing args to Falyx._create_context helper 2025-06-05 17:23:27 -04:00
Roland Thomas Jr ac82076511
Add filtering and options for History Command 2025-06-03 23:07:50 -04:00
Roland Thomas Jr 09eeb90dc6
Bubble up errors from CAP, catch a broader exception when parsing arguments, add type parsing to arg_metadata 2025-06-02 23:45:37 -04:00
Roland Thomas Jr e3ebc1b17b
Fix validation for empty input 2025-06-01 23:12:53 -04:00
Roland Thomas Jr 079bc0ee77
Normalize epilogue -> epilog, allow version to be modifiable, don't allow empty input in repl 2025-06-01 23:02:35 -04:00
Roland Thomas Jr 1c97857cb8
Centralize CAP creation in Command, Add better default type coercion 2025-06-01 17:38:48 -04:00
Roland Thomas Jr 21af003bc7
Update help formatting, allow help to be filtered by tag 2025-05-31 21:51:08 -04:00
Roland Thomas Jr 1585098513
Add init init-global to subparsers 2025-05-31 09:29:24 -04:00
Roland Thomas Jr 3d3a706784
Formatting of help text 2025-05-30 21:52:29 -04:00
Roland Thomas Jr c2eb854e5a
Add help_text for commands to argparse run subcommand, change the way Falyx.run works and you can only pass FalyxParsers 2025-05-30 00:36:55 -04:00
Roland Thomas Jr 8a3c1d6cc8
Fix global-init imports, passing args from command line to required commands 2025-05-28 17:11:26 -04:00
Roland Thomas Jr f196e38c57
Add ProcessPoolAction, update CAP to look only at keywords correctly 2025-05-28 00:58:50 -04:00
Roland Thomas Jr fb1ffbe9f6
Add ArgumentAction.ACTION, support POSIX bundling in CAP, Move all Actions to their own file 2025-05-25 19:25:32 -04:00
Roland Thomas Jr 429b434566
Remove emojis from logging statements 2025-05-24 17:53:34 -04:00
Roland Thomas Jr 4f3632bc6b
Remove emojis from logging statements 2025-05-24 15:09:39 -04:00
Roland Thomas Jr ba562168aa
hotfix syntax error < python3.12 2025-05-24 13:46:07 -04:00
Roland Thomas Jr ddb78bd5a7
Add PromptMenuAction, add cancel button to SelectionAction, make get_command async, add Action validation and defauilt nargs to None. 2025-05-24 12:29:16 -04:00
Roland Thomas Jr b0c0e7dc16
Fix run_group parser for REMAINDER, fix render_help formatting 2025-05-22 14:59:16 -04:00
Roland Thomas Jr 0a1ba22a3d
Remove args/kwargs being passed to generated Action from FactoryAction 2025-05-21 23:35:57 -04:00
Roland Thomas Jr b51ba87999
Add cancel for SelectionActions, Add args/kwargs to ActionFactoryAction, remove requires_input detection, add return types to SelectionAction, add option to hide_menu_table 2025-05-21 23:18:45 -04:00
Roland Thomas Jr 3c0a81359c
Make auto_args default fallback, integrate io_actions with argument parsing 2025-05-19 20:03:04 -04:00
Roland Thomas Jr 4fa6e3bf1f Merge pull request 'Add auto_args' (#3) from argparse-integration into main
Reviewed-on: #3
2025-05-18 22:27:27 -04:00
Roland Thomas Jr afa47b0bac
Add auto_args 2025-05-18 22:24:44 -04:00
Roland Thomas Jr 70a527358d Merge pull request 'Add CommandArgumentParser and integrate argument parsing from cli and menu prompt' (#2) from command-arg-parser into main
Reviewed-on: #2
2025-05-17 21:14:28 -04:00
Roland Thomas Jr 62276debd5
Add CommandArgumentParser and integrate argument parsing from cli and menu prompt 2025-05-17 21:10:50 -04:00
Roland Thomas Jr b14004c989
Add UserInputAction, coerce ActionFactoryAction to be async, add custom tables for MenuAction, Change Exit Command to use X 2025-05-14 20:28:28 -04:00
Roland Thomas Jr bba473047c
Add loading submenus from config or Falyx object, more examples 2025-05-13 23:19:29 -04:00
Roland Thomas Jr 2bdca72e04
Create action submodule, add various examples 2025-05-13 20:07:31 -04:00
Roland Thomas Jr 87a56ac40b
Linting 2025-05-13 00:18:04 -04:00
Roland Thomas Jr e999ad5e1c
Add better starter examples init.py, change validation in config.py, add add_command_from_command 2025-05-11 14:32:12 -04:00
Roland Thomas Jr 5c09f86b9b
Add help_text to preview, preview to menu, error handling for imports, summary printing for run and run-all, mandatory kwargs for most arguments 2025-05-10 09:56:48 -04:00
Roland Thomas Jr 9351ae658c
Add SelectFileAction, Remove GrepAction, UppercaseIO 2025-05-10 01:08:34 -04:00
Roland Thomas Jr 76e542cfce
Add ActionFactoryAction, Add mode flags for Falyx, Rename inject_last_result_as -> inject_into 2025-05-09 23:43:36 -04:00
Roland Thomas Jr ad803e01be
Add register_teardown, Rename session -> prompt_session, Add sets, tuples to SelectionAction 2025-05-08 22:10:05 -04:00
Roland Thomas Jr 53729f089f
Remove always_confirm 2025-05-08 00:56:16 -04:00
Roland Thomas Jr 7616216c26
Increment version 2025-05-08 00:53:49 -04:00
Roland Thomas Jr 26aab7f2d5
Fix HTTPAction close logic 2025-05-08 00:51:34 -04:00
Roland Thomas Jr 880d86d47d
Move spinner and confirmation logic from Falyx to Command 2025-05-08 00:45:24 -04:00
Roland Thomas Jr 05a7f982f2
Add test 2025-05-06 23:05:20 -04:00
Roland Thomas Jr 5d96d6d3d9
Change headless -> run_key, Add previews, _wrap_literal -> _wrap 2025-05-06 22:56:45 -04:00
Roland Thomas Jr b5da6b9647
Add dependencies 2025-05-05 22:41:42 -04:00
Roland Thomas Jr 2fee87ade9
Add tests for __main__.py 2025-05-05 18:32:57 -04:00
Roland Thomas Jr 6f159810b2
Add tests for __main__.py 2025-05-05 18:26:24 -04:00
Roland Thomas Jr a90c447d5c
Add init init-global 2025-05-04 20:13:25 -04:00
Roland Thomas Jr f9cb9ebaef
Change color -> style, Add SelectionOption 2025-05-04 19:35:44 -04:00
Roland Thomas Jr 91c4d5481f
Add MenuAction, SelectionAction, SignalAction, never_prompt(options_manager propagation), Merged prepare 2025-05-04 14:11:03 -04:00
Roland Thomas Jr 69b629eb08
Fix logic error print menu 2025-05-01 22:46:41 -04:00
Roland Thomas Jr f6316599d4
Update __main__.py to sync entrypoint 2025-05-01 22:41:06 -04:00
Roland Thomas Jr b51c4ba4f7
Update pyproject.toml 2025-05-01 22:37:58 -04:00
Roland Thomas Jr 2d879561c9
Change __main__ to load falyx config from predefined locations 2025-05-01 22:32:36 -04:00
Roland Thomas Jr e91654ca27
Linting, pre-commit 2025-05-01 20:26:50 -04:00
Roland Thomas Jr 4b1a9ef718
Add retry_utils, update docstrings, add tests 2025-05-01 00:35:36 -04:00
Roland Thomas Jr b9529d85ce Merge pull request 'io-actions' (#1) from io-actions into main
Reviewed-on: #1
2025-04-30 22:26:27 -04:00
Roland Thomas Jr fe9758adbf
Add HTTPAction, update main demo 2025-04-30 22:23:59 -04:00
Roland Thomas Jr bc1637143c
Rename ResultsContext -> SharedContext 2025-04-30 21:45:11 -04:00
Roland Thomas Jr 80de941335
Hide ioactions, Add doc strings, Add tests 2025-04-29 16:34:20 -04:00
Roland Thomas Jr e9fdd9cec6
Add compatibility between BaseAction and BaseIOAction 2025-04-24 22:39:42 -04:00
Roland Thomas Jr 1fe0cd2675
Add io_action.py 2025-04-24 19:13:13 -04:00
Roland Thomas Jr 18163edab9
bottom_bar remove _items, add remove_item and clear functions 2025-04-24 19:08:37 -04:00
107 changed files with 10924 additions and 1501 deletions

1
.gitignore vendored
View File

@ -15,4 +15,3 @@ build/
.vscode/ .vscode/
coverage.xml coverage.xml
.coverage .coverage

23
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,23 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: isort
args: [--profile, black]
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 25.1.0
hooks:
- id: black
args: [-l, "90"]
- repo: local
hooks:
- id: sync-version
name: Sync version from pyproject.toml
entry: python scripts/sync_version.py
language: system
files: ^pyproject\.toml$

View File

@ -52,7 +52,8 @@ poetry install
import asyncio import asyncio
import random import random
from falyx import Falyx, Action, ChainedAction from falyx import Falyx
from falyx.action import Action, ChainedAction
# A flaky async step that fails randomly # A flaky async step that fails randomly
async def flaky_step(): async def flaky_step():
@ -62,8 +63,8 @@ async def flaky_step():
return "ok" return "ok"
# Create the actions # Create the actions
step1 = Action(name="step_1", action=flaky_step, retry=True) step1 = Action(name="step_1", action=flaky_step)
step2 = Action(name="step_2", action=flaky_step, retry=True) step2 = Action(name="step_2", action=flaky_step)
# Chain the actions # Chain the actions
chain = ChainedAction(name="my_pipeline", actions=[step1, step2]) chain = ChainedAction(name="my_pipeline", actions=[step1, step2])
@ -74,9 +75,9 @@ falyx.add_command(
key="R", key="R",
description="Run My Pipeline", description="Run My Pipeline",
action=chain, action=chain,
logging_hooks=True,
preview_before_confirm=True, preview_before_confirm=True,
confirm=True, confirm=True,
retry_all=True,
) )
# Entry point # Entry point

View File

@ -1,29 +1,33 @@
import asyncio import asyncio
from falyx import Action, ActionGroup, ChainedAction from falyx.action import Action, ActionGroup, ChainedAction
# Actions can be defined as synchronous functions # Actions can be defined as synchronous functions
# Falyx will automatically convert them to async functions # Falyx will automatically convert them to async functions
def hello() -> None: def hello() -> None:
print("Hello, world!") print("Hello, world!")
hello = Action(name="hello_action", action=hello)
hello_action = Action(name="hello_action", action=hello)
# Actions can be run by themselves or as part of a command or pipeline # Actions can be run by themselves or as part of a command or pipeline
asyncio.run(hello()) asyncio.run(hello_action())
# Actions are designed to be asynchronous first # Actions are designed to be asynchronous first
async def goodbye() -> None: async def goodbye() -> None:
print("Goodbye!") print("Goodbye!")
goodbye = Action(name="goodbye_action", action=goodbye)
goodbye_action = Action(name="goodbye_action", action=goodbye)
asyncio.run(goodbye()) asyncio.run(goodbye())
# Actions can be run in parallel # Actions can be run in parallel
group = ActionGroup(name="greeting_group", actions=[hello, goodbye]) group = ActionGroup(name="greeting_group", actions=[hello_action, goodbye_action])
asyncio.run(group()) asyncio.run(group())
# Actions can be run in a chain # Actions can be run in a chain
chain = ChainedAction(name="greeting_chain", actions=[hello, goodbye]) chain = ChainedAction(name="greeting_chain", actions=[hello_action, goodbye_action])
asyncio.run(chain()) asyncio.run(chain())

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=["15", "25", "35", "45", "55"],
title="Choose a Post ID to submit",
prompt_message="Post ID > ",
show_table=True,
)
# Factory that builds and executes the actual HTTP POST request
async 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,38 @@
import asyncio
from falyx import Falyx
from falyx.action import Action, ActionGroup
# Define a shared async function
async def say_hello(name: str, excited: bool = False):
if excited:
print(f"Hello, {name}!!!")
else:
print(f"Hello, {name}.")
# Wrap the same callable in multiple Actions
action1 = Action("say_hello_1", action=say_hello)
action2 = Action("say_hello_2", action=say_hello)
action3 = Action("say_hello_3", action=say_hello)
# Combine into an ActionGroup
group = ActionGroup(name="greet_group", actions=[action1, action2, action3])
flx = Falyx("Test Group")
flx.add_command(
key="G",
description="Greet someone with multiple variations.",
aliases=["greet", "hello"],
action=group,
arg_metadata={
"name": {
"help": "The name of the person to greet.",
},
"excited": {
"help": "Whether to greet excitedly.",
},
},
)
asyncio.run(flx.run())

View File

@ -0,0 +1,59 @@
import asyncio
from falyx import Falyx
from falyx.action import Action, ChainedAction
from falyx.utils import setup_logging
setup_logging()
async def deploy(service: str, region: str = "us-east-1", verbose: bool = False) -> str:
if verbose:
print(f"Deploying {service} to {region}...")
await asyncio.sleep(2)
if verbose:
print(f"{service} deployed successfully!")
return f"{service} deployed to {region}"
flx = Falyx("Deployment CLI")
flx.add_command(
key="D",
aliases=["deploy"],
description="Deploy",
help_text="Deploy a service to a specified region.",
action=Action(
name="deploy_service",
action=deploy,
),
arg_metadata={
"service": "Service name",
"region": {"help": "Deployment region", "choices": ["us-east-1", "us-west-2"]},
"verbose": {"help": "Enable verbose mode"},
},
tags=["deployment", "service"],
)
deploy_chain = ChainedAction(
name="DeployChain",
actions=[
Action(name="deploy_service", action=deploy),
Action(
name="notify",
action=lambda last_result: print(f"Notification: {last_result}"),
),
],
auto_inject=True,
)
flx.add_command(
key="N",
aliases=["notify"],
description="Deploy and Notify",
help_text="Deploy a service and notify.",
action=deploy_chain,
tags=["deployment", "service", "notification"],
)
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())

32
examples/falyx.yaml Normal file
View File

@ -0,0 +1,32 @@
commands:
- key: P
description: Pipeline Demo
action: pipeline_demo.pipeline
tags: [pipeline, demo]
help_text: Run Deployment Pipeline with retries.
- key: G
description: Run HTTP Action Group
action: http_demo.action_group
tags: [http, demo]
confirm: true
- 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.
submenus:
- key: C
description: Process Menu (From Config)
config: process.yaml
- key: U
description: Submenu From Python
submenu: submenu.submenu

162
examples/falyx_demo.py Normal file
View File

@ -0,0 +1,162 @@
"""
Falyx CLI Framework
Copyright (c) 2025 rtj.dev LLC.
Licensed under the MIT License. See LICENSE file for details.
"""
import asyncio
import random
from falyx.action import Action, ActionGroup, ChainedAction
from falyx.falyx import Falyx
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()
async def main() -> None:
"""Build and return a Falyx instance with all your commands."""
flx = Falyx(
title="🚀 Falyx CLI",
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())

6
examples/http.yaml Normal file
View File

@ -0,0 +1,6 @@
commands:
- key: T
description: HTTP Test
action: single_http.http_action
tags: [http, demo]
help_text: Run HTTP test.

66
examples/http_demo.py Normal file
View File

@ -0,0 +1,66 @@
import asyncio
from rich.console import Console
from falyx import Falyx
from falyx.action import ActionGroup, HTTPAction
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(
"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())

136
examples/menu_demo.py Normal file
View File

@ -0,0 +1,136 @@
import asyncio
import time
from falyx import Falyx
from falyx.action import (
Action,
ActionGroup,
ChainedAction,
MenuAction,
ProcessAction,
PromptMenuAction,
)
from falyx.menu import MenuOption, MenuOptionMap
from falyx.themes import OneColors
# 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_options = MenuOptionMap(
{
"A": MenuOption("Run basic Action", basic_action, style=OneColors.LIGHT_YELLOW),
"C": MenuOption("Run ChainedAction", chained, style=OneColors.MAGENTA),
"P": MenuOption("Run ActionGroup (parallel)", parallel, style=OneColors.CYAN),
"H": MenuOption("Run ProcessAction (heavy task)", process, style=OneColors.GREEN),
}
)
# Menu setup
menu = MenuAction(
name="main-menu",
title="Choose a task to run",
menu_options=menu_options,
)
prompt_menu = PromptMenuAction(
name="select-user",
menu_options=menu_options,
)
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,
)
flx.add_command(
key="P",
description="Show Prompt Menu",
action=prompt_menu,
logging_hooks=True,
)
if __name__ == "__main__":
asyncio.run(flx.run())

76
examples/pipeline_demo.py Normal file
View File

@ -0,0 +1,76 @@
import asyncio
from falyx import ExecutionRegistry as er
from falyx.action import Action, ActionGroup, ChainedAction, ProcessAction
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("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())

11
examples/process.yaml Normal file
View File

@ -0,0 +1,11 @@
commands:
- key: P
description: Pipeline Demo
action: pipeline_demo.pipeline
tags: [pipeline, demo]
help_text: Run Demployment Pipeline with retries.
submenus:
- key: C
description: HTTP Test (Nested From Config)
config: http.yaml

View File

@ -1,22 +1,36 @@
from falyx import Falyx, ProcessAction
from falyx.themes.colors import NordColors as nc
from rich.console import Console from rich.console import Console
from falyx import Falyx
from falyx.action import ProcessPoolAction
from falyx.action.process_pool_action import ProcessTask
from falyx.execution_registry import ExecutionRegistry as er
from falyx.themes import NordColors as nc
console = Console() console = Console()
falyx = Falyx(title="🚀 Process Pool Demo") falyx = Falyx(title="🚀 Process Pool Demo")
def generate_primes(n):
primes = [] def generate_primes(start: int = 2, end: int = 100_000) -> list[int]:
for num in range(2, n): primes: list[int] = []
console.print(f"Generating primes from {start} to {end}...", style=nc.YELLOW)
for num in range(start, end):
if all(num % p != 0 for p in primes): if all(num % p != 0 for p in primes):
primes.append(num) primes.append(num)
console.print(f"Generated {len(primes)} primes up to {n}.", style=nc.GREEN) console.print(
f"Generated {len(primes)} primes from {start} to {end}.", style=nc.GREEN
)
return primes return primes
# Will not block the event loop
heavy_action = ProcessAction("Prime Generator", generate_primes, args=(100_000,))
falyx.add_command("R", "Generate Primes", heavy_action, spinner=True) actions = [ProcessTask(task=generate_primes)]
# Will not block the event loop
heavy_action = ProcessPoolAction(
name="Prime Generator",
actions=actions,
)
falyx.add_command("R", "Generate Primes", heavy_action)
if __name__ == "__main__": if __name__ == "__main__":

32
examples/run_key.py Normal file
View File

@ -0,0 +1,32 @@
import asyncio
from falyx import Falyx
from falyx.action import Action
async def main():
state = {"count": 0}
async def flaky():
if not state["count"]:
state["count"] += 1
print("Flaky step failed, retrying...")
raise RuntimeError("Random failure!")
return "ok"
# Add a command that raises an exception
falyx.add_command(
key="E",
description="Error Command",
action=Action("flaky", flaky),
retry=True,
)
result = await falyx.run_key("E")
print(result)
assert result == "ok"
if __name__ == "__main__":
falyx = Falyx("Headless Recovery Test")
asyncio.run(main())

View File

@ -0,0 +1,30 @@
import asyncio
from falyx.action import SelectionAction
from falyx.selection import SelectionOption
from falyx.signals import CancelSignal
selections = {
"1": SelectionOption(
description="Production", value="3bc2616e-3696-11f0-a139-089204eb86ac"
),
"2": SelectionOption(
description="Staging", value="42f2cd84-3696-11f0-a139-089204eb86ac"
),
}
select = SelectionAction(
name="Select Deployment",
selections=selections,
title="Select a Deployment",
columns=2,
prompt_message="> ",
return_type="value",
show_table=True,
)
try:
print(asyncio.run(select()))
except CancelSignal:
print("Selection was cancelled.")

89
examples/shell_example.py Executable file
View File

@ -0,0 +1,89 @@
#!/usr/bin/env python
import asyncio
from falyx import Falyx
from falyx.action import Action, ChainedAction, ShellAction
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(
"on_success",
reporter.report,
)
a2 = Action("a2", a2, inject_last_result=True)
a2.hooks.register(
"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())

View File

@ -1,18 +1,22 @@
import asyncio import asyncio
import random import random
from falyx import Falyx, Action, ChainedAction from falyx import Falyx
from falyx.action import Action, ChainedAction
from falyx.utils import setup_logging from falyx.utils import setup_logging
setup_logging() setup_logging()
# A flaky async step that fails randomly # A flaky async step that fails randomly
async def flaky_step(): async def flaky_step() -> str:
await asyncio.sleep(0.2) await asyncio.sleep(0.2)
if random.random() < 0.5: if random.random() < 0.3:
raise RuntimeError("Random failure!") raise RuntimeError("Random failure!")
print("Flaky step succeeded!")
return "ok" return "ok"
# Create a retry handler # Create a retry handler
step1 = Action(name="step_1", action=flaky_step, retry=True) step1 = Action(name="step_1", action=flaky_step, retry=True)
step2 = Action(name="step_2", action=flaky_step, retry=True) step2 = Action(name="step_2", action=flaky_step, retry=True)

14
examples/single_http.py Normal file
View File

@ -0,0 +1,14 @@
import asyncio
from falyx.action import HTTPAction
http_action = HTTPAction(
name="Get Example",
method="GET",
url="https://jsonplaceholder.typicode.com/posts/1",
headers={"Accept": "application/json"},
retry=True,
)
if __name__ == "__main__":
asyncio.run(http_action())

53
examples/submenu.py Normal file
View File

@ -0,0 +1,53 @@
import asyncio
import random
from falyx import Falyx
from falyx.action import Action, ChainedAction
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())

100
examples/type_validation.py Normal file
View File

@ -0,0 +1,100 @@
import asyncio
from uuid import UUID, uuid4
from falyx import Falyx
from falyx.parsers import CommandArgumentParser
flx = Falyx("Test Type Validation")
def uuid_val(value: str) -> str:
"""Custom validator to ensure a string is a valid UUID."""
UUID(value)
return value
async def print_uuid(uuid: str) -> str:
"""Prints the UUID if valid."""
print(f"Valid UUID: {uuid}")
return uuid
flx.add_command(
"U",
"Print a valid UUID (arguemnts)",
print_uuid,
arguments=[
{
"flags": ["uuid"],
"type": uuid_val,
"help": "A valid UUID string",
}
],
)
def uuid_parser(parser: CommandArgumentParser) -> None:
"""Custom parser to ensure the UUID argument is valid."""
parser.add_argument(
"uuid",
type=uuid_val,
help="A valid UUID string",
)
flx.add_command(
"I",
"Print a valid UUID (argument_config)",
print_uuid,
argument_config=uuid_parser,
)
flx.add_command(
"D",
"Print a valid UUID (arg_metadata)",
print_uuid,
arg_metadata={
"uuid": {
"type": uuid_val,
"help": "A valid UUID string",
}
},
)
def custom_parser(arguments: list[str]) -> tuple[tuple, dict]:
"""Custom parser to ensure the UUID argument is valid."""
if len(arguments) != 1:
raise ValueError("Exactly one argument is required")
uuid_val(arguments[0])
return (arguments[0],), {}
flx.add_command(
"C",
"Print a valid UUID (custom_parser)",
print_uuid,
custom_parser=custom_parser,
)
async def generate_uuid() -> str:
"""Generates a new UUID."""
new_uuid = uuid4()
print(f"Generated UUID: {new_uuid}")
return new_uuid
flx.add_command(
"G",
"Generate a new UUID",
lambda: print(uuid4()),
)
async def main() -> None:
await flx.run()
if __name__ == "__main__":
asyncio.run(main())

View File

@ -0,0 +1,38 @@
import asyncio
from prompt_toolkit.validation import Validator
from falyx.action import Action, ChainedAction, UserInputAction
def validate_alpha() -> Validator:
def validate(text: str) -> bool:
return text.isalpha()
return Validator.from_callable(
validate,
error_message="Please enter only alphabetic characters.",
move_cursor_to_end=True,
)
chain = ChainedAction(
name="Demo Chain",
actions=[
"Name",
UserInputAction(
name="User Input",
prompt_text="Enter your {last_result}: ",
validator=validate_alpha(),
),
Action(
name="Display Name",
action=lambda last_result: print(f"Hello, {last_result}!"),
),
],
auto_inject=True,
)
if __name__ == "__main__":
asyncio.run(chain.preview())
asyncio.run(chain())

View File

@ -1,23 +1,18 @@
"""
Falyx CLI Framework
Copyright (c) 2025 rtj.dev LLC.
Licensed under the MIT License. See LICENSE file for details.
"""
import logging import logging
from .action import Action, ActionGroup, ChainedAction, ProcessAction
from .command import Command
from .context import ExecutionContext, ResultsContext
from .execution_registry import ExecutionRegistry from .execution_registry import ExecutionRegistry
from .falyx import Falyx from .falyx import Falyx
logger = logging.getLogger("falyx") logger = logging.getLogger("falyx")
__version__ = "0.1.0"
__all__ = [ __all__ = [
"Action",
"ChainedAction",
"ActionGroup",
"ProcessAction",
"Falyx", "Falyx",
"Command",
"ExecutionContext",
"ResultsContext",
"ExecutionRegistry", "ExecutionRegistry",
] ]

View File

@ -1,42 +1,120 @@
# falyx/__main__.py """
Falyx CLI Framework
Copyright (c) 2025 rtj.dev LLC.
Licensed under the MIT License. See LICENSE file for details.
"""
import asyncio import asyncio
import logging import os
import sys
from argparse import ArgumentParser, Namespace, _SubParsersAction
from pathlib import Path
from typing import Any
from falyx.action import Action from falyx.config import loader
from falyx.falyx import Falyx from falyx.falyx import Falyx
from falyx.parsers import CommandArgumentParser, get_root_parser, get_subparsers
def build_falyx() -> Falyx: def find_falyx_config() -> Path | None:
"""Build and return a Falyx instance with all your commands.""" candidates = [
app = Falyx(title="🚀 Falyx CLI") Path.cwd() / "falyx.yaml",
Path.cwd() / "falyx.toml",
Path.cwd() / ".falyx.yaml",
Path.cwd() / ".falyx.toml",
Path(os.environ.get("FALYX_CONFIG", "falyx.yaml")),
Path.home() / ".config" / "falyx" / "falyx.yaml",
Path.home() / ".config" / "falyx" / "falyx.toml",
Path.home() / ".falyx.yaml",
Path.home() / ".falyx.toml",
]
return next((p for p in candidates if p.exists()), None)
# Example commands
app.add_command( def bootstrap() -> Path | None:
key="B", config_path = find_falyx_config()
description="Build project", if config_path and str(config_path.parent) not in sys.path:
action=Action("Build", lambda: print("📦 Building...")), sys.path.insert(0, str(config_path.parent))
tags=["build"] return config_path
def init_config(parser: CommandArgumentParser) -> None:
parser.add_argument(
"name",
type=str,
help="Name of the new Falyx project",
default=".",
nargs="?",
) )
app.add_command(
key="T", def init_callback(args: Namespace) -> None:
description="Run tests", """Callback for the init command."""
action=Action("Test", lambda: print("🧪 Running tests...")), if args.command == "init":
tags=["test"] from falyx.init import init_project
init_project(args.name)
elif args.command == "init_global":
from falyx.init import init_global
init_global()
def get_parsers() -> tuple[ArgumentParser, _SubParsersAction]:
root_parser: ArgumentParser = get_root_parser()
subparsers = get_subparsers(root_parser)
init_parser = subparsers.add_parser(
"init",
help="Initialize a new Falyx project",
description="Create a new Falyx project with mock configuration files.",
epilog="If no name is provided, the current directory will be used.",
)
init_parser.add_argument(
"name",
type=str,
help="Name of the new Falyx project",
default=".",
nargs="?",
)
subparsers.add_parser(
"init-global",
help="Initialize Falyx global configuration",
description="Create a global Falyx configuration at ~/.config/falyx/.",
)
return root_parser, subparsers
def main() -> Any:
bootstrap_path = bootstrap()
if not bootstrap_path:
from falyx.init import init_global, init_project
flx: Falyx = Falyx()
flx.add_command(
"I",
"Initialize a new Falyx project",
init_project,
aliases=["init"],
argument_config=init_config,
help_epilog="If no name is provided, the current directory will be used.",
)
flx.add_command(
"G",
"Initialize Falyx global configuration",
init_global,
aliases=["init-global"],
help_text="Create a global Falyx configuration at ~/.config/falyx/.",
)
else:
flx = loader(bootstrap_path)
root_parser, subparsers = get_parsers()
return asyncio.run(
flx.run(root_parser=root_parser, subparsers=subparsers, callback=init_callback)
) )
app.add_command(
key="D",
description="Deploy project",
action=Action("Deploy", lambda: print("🚀 Deploying...")),
tags=["deploy"]
)
return app
if __name__ == "__main__": if __name__ == "__main__":
logging.basicConfig(level=logging.WARNING) main()
falyx = build_falyx()
asyncio.run(falyx.run())

View File

@ -1,537 +0,0 @@
"""action.py
Any Action or Command is callable and supports the signature:
result = thing(*args, **kwargs)
This guarantees:
- Hook lifecycle (before/after/error/teardown)
- Timing
- Consistent return values
"""
from __future__ import annotations
import asyncio
import inspect
import random
from abc import ABC, abstractmethod
from concurrent.futures import ProcessPoolExecutor
from functools import partial
from typing import Any, Callable
from rich.console import Console
from rich.tree import Tree
from falyx.context import ExecutionContext, ResultsContext
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 Hook, HookManager, HookType
from falyx.retry import RetryHandler, RetryPolicy
from falyx.themes.colors import OneColors
from falyx.utils import ensure_async, logger
console = Console()
class BaseAction(ABC):
"""
Base class for actions. Actions can be simple functions or more
complex actions like `ChainedAction` or `ActionGroup`. They can also
be run independently or as part of Menu.
inject_last_result (bool): Whether to inject the previous action's result into kwargs.
inject_last_result_as (str): The name of the kwarg key to inject the result as
(default: 'last_result').
"""
def __init__(
self,
name: str,
hooks: HookManager | None = None,
inject_last_result: bool = False,
inject_last_result_as: str = "last_result",
logging_hooks: bool = False,
) -> None:
self.name = name
self.hooks = hooks or HookManager()
self.is_retryable: bool = False
self.results_context: ResultsContext | None = None
self.inject_last_result: bool = inject_last_result
self.inject_last_result_as: str = inject_last_result_as
if logging_hooks:
register_debug_hooks(self.hooks)
async def __call__(self, *args, **kwargs) -> Any:
return await self._run(*args, **kwargs)
@abstractmethod
async def _run(self, *args, **kwargs) -> Any:
raise NotImplementedError("_run must be implemented by subclasses")
@abstractmethod
async def preview(self, parent: Tree | None = None):
raise NotImplementedError("preview must be implemented by subclasses")
def set_results_context(self, results_context: ResultsContext):
self.results_context = results_context
def prepare_for_chain(self, results_context: ResultsContext) -> BaseAction:
"""
Prepare the action specifically for sequential (ChainedAction) execution.
Can be overridden for chain-specific logic.
"""
self.set_results_context(results_context)
return self
def prepare_for_group(self, results_context: ResultsContext) -> BaseAction:
"""
Prepare the action specifically for parallel (ActionGroup) execution.
Can be overridden for group-specific logic.
"""
self.set_results_context(results_context)
return self
def _maybe_inject_last_result(self, kwargs: dict[str, Any]) -> dict[str, Any]:
if self.inject_last_result and self.results_context:
key = self.inject_last_result_as
if key in kwargs:
logger.warning("[%s] ⚠️ Overriding '%s' with last_result", self.name, key)
kwargs = dict(kwargs)
kwargs[key] = self.results_context.last_result()
return kwargs
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)
@classmethod
def enable_retries_recursively(cls, action: BaseAction, policy: RetryPolicy | None):
if not policy:
policy = RetryPolicy(enabled=True)
if isinstance(action, Action):
action.retry_policy = policy
action.retry_policy.enabled = True
action.hooks.register(HookType.ON_ERROR, RetryHandler(policy).retry_on_error)
if hasattr(action, "actions"):
for sub in action.actions:
cls.enable_retries_recursively(sub, policy)
async def _write_stdout(self, data: str) -> None:
"""Override in subclasses that produce terminal output."""
pass
def __str__(self):
return f"<{self.__class__.__name__} '{self.name}'>"
def __repr__(self):
return str(self)
def __or__(self, other: BaseAction) -> ChainedAction:
"""Chain this action with another action."""
if not isinstance(other, BaseAction):
raise FalyxError(f"Cannot chain {type(other)} with {type(self)}")
return ChainedAction(name=f"{self.name} | {other.name}", actions=[self, other])
async def __ror__(self, other: Any):
if inspect.isawaitable(other):
print(1)
other = await other
if self.inject_last_result:
print(2)
return await self(**{self.inject_last_result_as: other})
literal_action = Action(
name=f"Input | {self.name}",
action=lambda: other,
)
chain = ChainedAction(name=f"{other} | {self.name}", actions=[literal_action, self])
print(3)
print(self.name, other)
return await chain()
class Action(BaseAction):
"""A simple action that runs a callable. It can be a function or a coroutine."""
def __init__(
self,
name: str,
action,
rollback=None,
args: tuple[Any, ...] = (),
kwargs: dict[str, Any] | None = None,
hooks: HookManager | None = None,
inject_last_result: bool = False,
inject_last_result_as: str = "last_result",
retry: bool = False,
retry_policy: RetryPolicy | None = None,
) -> None:
super().__init__(name, hooks, inject_last_result, inject_last_result_as)
self.action = ensure_async(action)
self.rollback = rollback
self.args = args
self.kwargs = kwargs or {}
self.is_retryable = True
self.retry_policy = retry_policy or RetryPolicy()
if retry or (retry_policy and retry_policy.enabled):
self.enable_retry()
def enable_retry(self):
"""Enable retry with the existing retry policy."""
self.retry_policy.enabled = True
logger.debug(f"[Action:{self.name}] Registering retry handler")
handler = RetryHandler(self.retry_policy)
self.hooks.register(HookType.ON_ERROR, handler.retry_on_error)
def set_retry_policy(self, policy: RetryPolicy):
"""Set a new retry policy and re-register the handler."""
self.retry_policy = policy
self.enable_retry()
async def _run(self, *args, **kwargs) -> Any:
combined_args = args + self.args
combined_kwargs = self._maybe_inject_last_result({**self.kwargs, **kwargs})
context = ExecutionContext(
name=self.name,
args=combined_args,
kwargs=combined_kwargs,
action=self,
)
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
result = await self.action(*combined_args, **combined_kwargs)
context.result = result
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return context.result
except Exception as error:
context.exception = error
await self.hooks.trigger(HookType.ON_ERROR, context)
if context.result is not None:
logger.info("[%s] ✅ Recovered: %s", self.name, self.name)
return context.result
raise error
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.GREEN_b}]⚙ Action[/] '{self.name}'"]
if self.inject_last_result:
label.append(f" [dim](injects '{self.inject_last_result_as}')[/dim]")
if self.retry_policy.enabled:
label.append(
f"\n[dim]↻ Retries:[/] {self.retry_policy.max_retries}x, "
f"delay {self.retry_policy.delay}s, backoff {self.retry_policy.backoff}x"
)
if parent:
parent.add("".join(label))
else:
console.print(Tree("".join(label)))
class LiteralInputAction(Action):
def __init__(self, value: Any):
async def literal(*args, **kwargs): return value
super().__init__("Input", literal, inject_last_result=True)
class ActionListMixin:
"""Mixin for managing a list of actions."""
def __init__(self) -> None:
self.actions: list[BaseAction] = []
def set_actions(self, actions: list[BaseAction]) -> None:
"""Replaces the current action list with a new one."""
self.actions.clear()
for action in actions:
self.add_action(action)
def add_action(self, action: BaseAction) -> None:
"""Adds an action to the list."""
self.actions.append(action)
def remove_action(self, name: str) -> None:
"""Removes an action by name."""
self.actions = [action for action in self.actions if action.name != name]
def has_action(self, name: str) -> bool:
"""Checks if an action with the given name exists."""
return any(action.name == name for action in self.actions)
def get_action(self, name: str) -> BaseAction | None:
"""Retrieves an action by name."""
for action in self.actions:
if action.name == name:
return action
return None
class ChainedAction(BaseAction, ActionListMixin):
"""A ChainedAction is a sequence of actions that are executed in order."""
def __init__(
self,
name: str,
actions: list[BaseAction] | None = None,
hooks: HookManager | None = None,
inject_last_result: bool = False,
inject_last_result_as: str = "last_result",
) -> None:
super().__init__(name, hooks, inject_last_result, inject_last_result_as)
ActionListMixin.__init__(self)
if actions:
self.set_actions(actions)
async def _run(self, *args, **kwargs) -> list[Any]:
results_context = ResultsContext(name=self.name)
if self.results_context:
results_context.add_result(self.results_context.last_result())
updated_kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext(
name=self.name,
args=args,
kwargs=updated_kwargs,
action=self,
extra={"results": [], "rollback_stack": []},
)
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
last_result = self.results_context.last_result() if self.results_context else None
for index, action in enumerate(self.actions):
results_context.current_index = index
prepared = action.prepare_for_chain(results_context)
run_kwargs = dict(updated_kwargs)
underlying = getattr(prepared, "action", None)
if underlying:
signature = inspect.signature(underlying)
else:
signature = inspect.signature(prepared._run)
parameters = signature.parameters
if last_result is not None:
if action.inject_last_result_as in parameters:
run_kwargs[action.inject_last_result_as] = last_result
result = await prepared(*args, **run_kwargs)
elif (
len(parameters) == 1 and
not parameters.get("self")
):
result = await prepared(last_result)
else:
result = await prepared(*args, **updated_kwargs)
else:
result = await prepared(*args, **updated_kwargs)
last_result = result
results_context.add_result(result)
context.extra["results"].append(result)
context.extra["rollback_stack"].append(prepared)
context.result = last_result
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return last_result
except Exception as error:
context.exception = error
results_context.errors.append((results_context.current_index, error))
await self._rollback(context.extra["rollback_stack"], *args, **kwargs)
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 _rollback(self, rollback_stack, *args, **kwargs):
for action in reversed(rollback_stack):
rollback = getattr(action, "rollback", None)
if rollback:
try:
logger.warning("[%s] ↩️ Rolling back...", action.name)
await action.rollback(*args, **kwargs)
except Exception as error:
logger.error("[%s]⚠️ Rollback failed: %s", action.name, error)
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.CYAN_b}]⛓ ChainedAction[/] '{self.name}'"]
if self.inject_last_result:
label.append(f" [dim](injects '{self.inject_last_result_as}')[/dim]")
tree = parent.add("".join(label)) if parent else Tree("".join(label))
for action in self.actions:
await action.preview(parent=tree)
if not parent:
console.print(tree)
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)
for action in self.actions:
action.register_hooks_recursively(hook_type, hook)
class ActionGroup(BaseAction, ActionListMixin):
"""An ActionGroup is a collection of actions that can be run in parallel."""
def __init__(
self,
name: str,
actions: list[BaseAction] | None = None,
hooks: HookManager | None = None,
inject_last_result: bool = False,
inject_last_result_as: str = "last_result",
):
super().__init__(name, hooks, inject_last_result, inject_last_result_as)
ActionListMixin.__init__(self)
if actions:
self.set_actions(actions)
async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]:
results_context = ResultsContext(name=self.name, is_parallel=True)
if self.results_context:
results_context.set_shared_result(self.results_context.last_result())
updated_kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext(
name=self.name,
args=args,
kwargs=updated_kwargs,
action=self,
extra={"results": [], "errors": []},
)
async def run_one(action: BaseAction):
try:
prepared = action.prepare_for_group(results_context)
result = await prepared(*args, **updated_kwargs)
results_context.add_result((action.name, result))
context.extra["results"].append((action.name, result))
except Exception as error:
results_context.errors.append((results_context.current_index, error))
context.extra["errors"].append((action.name, error))
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
await asyncio.gather(*[run_one(a) for a in self.actions])
if context.extra["errors"]:
context.exception = Exception(
f"{len(context.extra['errors'])} action(s) failed: "
f"{' ,'.join(name for name, _ in context.extra["errors"])}"
)
await self.hooks.trigger(HookType.ON_ERROR, context)
raise context.exception
context.result = context.extra["results"]
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return context.result
except Exception as error:
context.exception = error
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.MAGENTA_b}]⏩ ActionGroup (parallel)[/] '{self.name}'"]
if self.inject_last_result:
label.append(f" [dim](receives '{self.inject_last_result_as}')[/dim]")
tree = parent.add("".join(label)) if parent else Tree("".join(label))
actions = self.actions.copy()
random.shuffle(actions)
await asyncio.gather(*(action.preview(parent=tree) for action in actions))
if not parent:
console.print(tree)
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
"""Register a hook for all actions and sub-actions."""
super().register_hooks_recursively(hook_type, hook)
for action in self.actions:
action.register_hooks_recursively(hook_type, hook)
class ProcessAction(BaseAction):
"""A ProcessAction runs a function in a separate process using ProcessPoolExecutor."""
def __init__(
self,
name: str,
func: Callable[..., Any],
args: tuple = (),
kwargs: dict[str, Any] | None = None,
hooks: HookManager | None = None,
executor: ProcessPoolExecutor | None = None,
inject_last_result: bool = False,
inject_last_result_as: str = "last_result",
):
super().__init__(name, hooks, inject_last_result, inject_last_result_as)
self.func = func
self.args = args
self.kwargs = kwargs or {}
self.executor = executor or ProcessPoolExecutor()
self.is_retryable = True
async def _run(self, *args, **kwargs):
if self.inject_last_result:
last_result = self.results_context.last_result()
if not self._validate_pickleable(last_result):
raise ValueError(
f"Cannot inject last result into {self.name}: "
f"last result is not pickleable."
)
combined_args = args + self.args
combined_kwargs = self._maybe_inject_last_result({**self.kwargs, **kwargs})
context = ExecutionContext(
name=self.name,
args=combined_args,
kwargs=combined_kwargs,
action=self,
)
loop = asyncio.get_running_loop()
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
result = await loop.run_in_executor(
self.executor, partial(self.func, *combined_args, **combined_kwargs)
)
context.result = 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)
if context.result is not None:
return context.result
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.DARK_YELLOW_b}]🧠 ProcessAction (new process)[/] '{self.name}'"]
if self.inject_last_result:
label.append(f" [dim](injects '{self.inject_last_result_as}')[/dim]")
if parent:
parent.add("".join(label))
else:
console.print(Tree("".join(label)))
def _validate_pickleable(self, obj: Any) -> bool:
try:
import pickle
pickle.dumps(obj)
return True
except (pickle.PicklingError, TypeError):
return False

0
falyx/action/.pytyped Normal file
View File

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

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

162
falyx/action/action.py Normal file
View File

@ -0,0 +1,162 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""action.py"""
from __future__ import annotations
from typing import Any, Callable
from rich.tree import Tree
from falyx.action.base import BaseAction
from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType
from falyx.logger import logger
from falyx.retry import RetryHandler, RetryPolicy
from falyx.themes import OneColors
from falyx.utils import ensure_async
class Action(BaseAction):
"""
Action wraps a simple function or coroutine into a standard executable unit.
It supports:
- Optional retry logic.
- Hook lifecycle (before, success, error, after, teardown).
- Last result injection for chaining.
- Optional rollback handlers for undo logic.
Args:
name (str): Name of the action.
action (Callable): The function or coroutine to execute.
rollback (Callable, optional): Rollback function to undo the action.
args (tuple, optional): Static positional arguments.
kwargs (dict, optional): Static keyword arguments.
hooks (HookManager, optional): Hook manager for lifecycle events.
inject_last_result (bool, optional): Enable last_result injection.
inject_into (str, optional): Name of injected key.
retry (bool, optional): Enable retry logic.
retry_policy (RetryPolicy, optional): Retry settings.
"""
def __init__(
self,
name: str,
action: Callable[..., Any],
*,
rollback: Callable[..., Any] | None = None,
args: tuple[Any, ...] = (),
kwargs: dict[str, Any] | None = None,
hooks: HookManager | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
retry: bool = False,
retry_policy: RetryPolicy | None = None,
) -> None:
super().__init__(
name,
hooks=hooks,
inject_last_result=inject_last_result,
inject_into=inject_into,
)
self.action = action
self.rollback = rollback
self.args = args
self.kwargs = kwargs or {}
self.is_retryable = True
self.retry_policy = retry_policy or RetryPolicy()
if retry or (retry_policy and retry_policy.enabled):
self.enable_retry()
@property
def action(self) -> Callable[..., Any]:
return self._action
@action.setter
def action(self, value: Callable[..., Any]):
self._action = ensure_async(value)
@property
def rollback(self) -> Callable[..., Any] | None:
return self._rollback
@rollback.setter
def rollback(self, value: Callable[..., Any] | None):
if value is None:
self._rollback = None
else:
self._rollback = ensure_async(value)
def enable_retry(self):
"""Enable retry with the existing retry policy."""
self.retry_policy.enable_policy()
logger.debug("[%s] Registering retry handler", self.name)
handler = RetryHandler(self.retry_policy)
self.hooks.register(HookType.ON_ERROR, handler.retry_on_error)
def set_retry_policy(self, policy: RetryPolicy):
"""Set a new retry policy and re-register the handler."""
self.retry_policy = policy
if policy.enabled:
self.enable_retry()
def get_infer_target(self) -> tuple[Callable[..., Any], None]:
"""
Returns the callable to be used for argument inference.
By default, it returns the action itself.
"""
return self.action, None
async def _run(self, *args, **kwargs) -> Any:
combined_args = args + self.args
combined_kwargs = self._maybe_inject_last_result({**self.kwargs, **kwargs})
context = ExecutionContext(
name=self.name,
args=combined_args,
kwargs=combined_kwargs,
action=self,
)
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
result = await self.action(*combined_args, **combined_kwargs)
context.result = result
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return context.result
except Exception as error:
context.exception = error
await self.hooks.trigger(HookType.ON_ERROR, context)
if context.result is not None:
logger.info("[%s] Recovered: %s", self.name, self.name)
return context.result
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.GREEN_b}]⚙ Action[/] '{self.name}'"]
if self.inject_last_result:
label.append(f" [dim](injects '{self.inject_into}')[/dim]")
if self.retry_policy.enabled:
label.append(
f"\n[dim]↻ Retries:[/] {self.retry_policy.max_retries}x, "
f"delay {self.retry_policy.delay}s, backoff {self.retry_policy.backoff}x"
)
if parent:
parent.add("".join(label))
else:
self.console.print(Tree("".join(label)))
def __str__(self):
return (
f"Action(name={self.name!r}, action="
f"{getattr(self._action, '__name__', repr(self._action))}, "
f"args={self.args!r}, kwargs={self.kwargs!r}, "
f"retry={self.retry_policy.enabled})"
)

View File

@ -0,0 +1,126 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""action_factory.py"""
from typing import Any, Callable
from rich.tree import Tree
from falyx.action.base 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 import OneColors
from falyx.utils import ensure_async
class ActionFactoryAction(BaseAction):
"""
Dynamically creates and runs another Action at runtime using a factory function.
This is useful for generating context-specific behavior (e.g., dynamic HTTPActions)
where the structure of the next action depends on runtime values.
Args:
name (str): Name of the action.
factory (Callable): A function that returns a BaseAction given args/kwargs.
inject_last_result (bool): Whether to inject last_result into the factory.
inject_into (str): The name of the kwarg to inject last_result as.
"""
def __init__(
self,
name: str,
factory: ActionFactoryProtocol,
*,
inject_last_result: bool = False,
inject_into: str = "last_result",
args: tuple[Any, ...] = (),
kwargs: dict[str, Any] | None = None,
preview_args: tuple[Any, ...] = (),
preview_kwargs: dict[str, Any] | None = None,
):
super().__init__(
name=name,
inject_last_result=inject_last_result,
inject_into=inject_into,
)
self.factory = factory
self.args = args
self.kwargs = kwargs or {}
self.preview_args = preview_args
self.preview_kwargs = preview_kwargs or {}
@property
def factory(self) -> ActionFactoryProtocol:
return self._factory # type: ignore[return-value]
@factory.setter
def factory(self, value: ActionFactoryProtocol):
self._factory = ensure_async(value)
def get_infer_target(self) -> tuple[Callable[..., Any], None]:
return self.factory, None
async def _run(self, *args, **kwargs) -> Any:
args = (*self.args, *args)
kwargs = {**self.kwargs, **kwargs}
updated_kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext(
name=f"{self.name} (factory)",
args=args,
kwargs=updated_kwargs,
action=self,
)
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
generated_action = await self.factory(*args, **updated_kwargs)
if not isinstance(generated_action, BaseAction):
raise TypeError(
f"[{self.name}] Factory must return a BaseAction, got "
f"{type(generated_action).__name__}"
)
if self.shared_context:
generated_action.set_shared_context(self.shared_context)
if hasattr(generated_action, "register_teardown") and callable(
generated_action.register_teardown
):
generated_action.register_teardown(self.shared_context.action.hooks)
logger.debug(
"[%s] Registered teardown for %s",
self.name,
generated_action.name,
)
if self.options_manager:
generated_action.set_options_manager(self.options_manager)
context.result = await generated_action()
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return context.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):
label = f"[{OneColors.CYAN_b}]🏗️ ActionFactory[/] '{self.name}'"
tree = parent.add(label) if parent else Tree(label)
try:
generated = await self.factory(*self.preview_args, **self.preview_kwargs)
if isinstance(generated, BaseAction):
await generated.preview(parent=tree)
else:
tree.add(
f"[{OneColors.DARK_RED}]⚠️ Factory did not return a BaseAction[/]"
)
except Exception as error:
tree.add(f"[{OneColors.DARK_RED}]⚠️ Preview failed: {error}[/]")
if not parent:
self.console.print(tree)

View File

@ -0,0 +1,170 @@
import asyncio
import random
from typing import Any, Callable
from rich.tree import Tree
from falyx.action.action import Action
from falyx.action.base import BaseAction
from falyx.action.mixins import ActionListMixin
from falyx.context import ExecutionContext, SharedContext
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import Hook, HookManager, HookType
from falyx.logger import logger
from falyx.parsers.utils import same_argument_definitions
from falyx.themes.colors import OneColors
class ActionGroup(BaseAction, ActionListMixin):
"""
ActionGroup executes multiple actions concurrently in parallel.
It is ideal for independent tasks that can be safely run simultaneously,
improving overall throughput and responsiveness of workflows.
Core features:
- Parallel execution of all contained actions.
- Shared last_result injection across all actions if configured.
- Aggregated collection of individual results as (name, result) pairs.
- Hook lifecycle support (before, on_success, on_error, after, on_teardown).
- Error aggregation: captures all action errors and reports them together.
Behavior:
- If any action fails, the group collects the errors but continues executing
other actions without interruption.
- After all actions complete, ActionGroup raises a single exception summarizing
all failures, or returns all results if successful.
Best used for:
- Batch processing multiple independent tasks.
- Reducing latency for workflows with parallelizable steps.
- Isolating errors while maximizing successful execution.
Args:
name (str): Name of the chain.
actions (list): List of actions or literals to execute.
hooks (HookManager, optional): Hooks for lifecycle events.
inject_last_result (bool, optional): Whether to inject last results into kwargs
by default.
inject_into (str, optional): Key name for injection.
"""
def __init__(
self,
name: str,
actions: list[BaseAction] | None = None,
*,
hooks: HookManager | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
):
super().__init__(
name,
hooks=hooks,
inject_last_result=inject_last_result,
inject_into=inject_into,
)
ActionListMixin.__init__(self)
if actions:
self.set_actions(actions)
def _wrap_if_needed(self, action: BaseAction | Any) -> BaseAction:
if isinstance(action, BaseAction):
return action
elif callable(action):
return Action(name=action.__name__, action=action)
else:
raise TypeError(
"ActionGroup only accepts BaseAction or callable, got "
f"{type(action).__name__}"
)
def add_action(self, action: BaseAction | Any) -> None:
action = self._wrap_if_needed(action)
super().add_action(action)
if hasattr(action, "register_teardown") and callable(action.register_teardown):
action.register_teardown(self.hooks)
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
arg_defs = same_argument_definitions(self.actions)
if arg_defs:
return self.actions[0].get_infer_target()
logger.debug(
"[%s] auto_args disabled: mismatched ActionGroup arguments",
self.name,
)
return None, None
async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]:
shared_context = SharedContext(name=self.name, action=self, is_parallel=True)
if self.shared_context:
shared_context.set_shared_result(self.shared_context.last_result())
updated_kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext(
name=self.name,
args=args,
kwargs=updated_kwargs,
action=self,
extra={"results": [], "errors": []},
shared_context=shared_context,
)
async def run_one(action: BaseAction):
try:
prepared = action.prepare(shared_context, self.options_manager)
result = await prepared(*args, **updated_kwargs)
shared_context.add_result((action.name, result))
context.extra["results"].append((action.name, result))
except Exception as error:
shared_context.add_error(shared_context.current_index, error)
context.extra["errors"].append((action.name, error))
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
await asyncio.gather(*[run_one(a) for a in self.actions])
if context.extra["errors"]:
context.exception = Exception(
f"{len(context.extra['errors'])} action(s) failed: "
f"{' ,'.join(name for name, _ in context.extra['errors'])}"
)
await self.hooks.trigger(HookType.ON_ERROR, context)
raise context.exception
context.result = context.extra["results"]
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return context.result
except Exception as error:
context.exception = error
raise
finally:
context.stop_timer()
await self.hooks.trigger(HookType.AFTER, context)
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
er.record(context)
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
"""Register a hook for all actions and sub-actions."""
super().register_hooks_recursively(hook_type, hook)
for action in self.actions:
action.register_hooks_recursively(hook_type, hook)
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.MAGENTA_b}]⏩ ActionGroup (parallel)[/] '{self.name}'"]
if self.inject_last_result:
label.append(f" [dim](receives '{self.inject_into}')[/dim]")
tree = parent.add("".join(label)) if parent else Tree("".join(label))
actions = self.actions.copy()
random.shuffle(actions)
await asyncio.gather(*(action.preview(parent=tree) for action in actions))
if not parent:
self.console.print(tree)
def __str__(self):
return (
f"ActionGroup(name={self.name!r}, actions={[a.name for a in self.actions]!r},"
f" inject_last_result={self.inject_last_result}, "
f"inject_into={self.inject_into!r})"
)

156
falyx/action/base.py Normal file
View File

@ -0,0 +1,156 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""base.py
Core action system for Falyx.
This module defines the building blocks for executable actions and workflows,
providing a structured way to compose, execute, recover, and manage sequences of
operations.
All actions are callable and follow a unified signature:
result = action(*args, **kwargs)
Core guarantees:
- Full hook lifecycle support (before, on_success, on_error, after, on_teardown).
- Consistent timing and execution context tracking for each run.
- Unified, predictable result handling and error propagation.
- Optional last_result injection to enable flexible, data-driven workflows.
- Built-in support for retries, rollbacks, parallel groups, chaining, and fallback
recovery.
Key components:
- Action: wraps a function or coroutine into a standard executable unit.
- ChainedAction: runs actions sequentially, optionally injecting last results.
- ActionGroup: runs actions in parallel and gathers results.
- ProcessAction: executes CPU-bound functions in a separate process.
- LiteralInputAction: injects static values into workflows.
- FallbackAction: gracefully recovers from failures or missing data.
This design promotes clean, fault-tolerant, modular CLI and automation systems.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, Callable
from rich.console import Console
from rich.tree import Tree
from falyx.context import SharedContext
from falyx.debug import register_debug_hooks
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
class BaseAction(ABC):
"""
Base class for actions. Actions can be simple functions or more
complex actions like `ChainedAction` or `ActionGroup`. They can also
be run independently or as part of Falyx.
inject_last_result (bool): Whether to inject the previous action's result
into kwargs.
inject_into (str): The name of the kwarg key to inject the result as
(default: 'last_result').
"""
def __init__(
self,
name: str,
*,
hooks: HookManager | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
never_prompt: bool = False,
logging_hooks: bool = False,
) -> None:
self.name = name
self.hooks = hooks or HookManager()
self.is_retryable: bool = False
self.shared_context: SharedContext | None = None
self.inject_last_result: bool = inject_last_result
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.options_manager: OptionsManager | None = None
if logging_hooks:
register_debug_hooks(self.hooks)
async def __call__(self, *args, **kwargs) -> Any:
return await self._run(*args, **kwargs)
@abstractmethod
async def _run(self, *args, **kwargs) -> Any:
raise NotImplementedError("_run must be implemented by subclasses")
@abstractmethod
async def preview(self, parent: Tree | None = None):
raise NotImplementedError("preview must be implemented by subclasses")
@abstractmethod
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
"""
Returns the callable to be used for argument inference.
By default, it returns None.
"""
raise NotImplementedError("get_infer_target must be implemented by subclasses")
def set_options_manager(self, options_manager: OptionsManager) -> None:
self.options_manager = options_manager
def set_shared_context(self, shared_context: SharedContext) -> None:
self.shared_context = shared_context
def get_option(self, option_name: str, default: Any = None) -> Any:
"""
Resolve an option from the OptionsManager if present, otherwise use the fallback.
"""
if self.options_manager:
return self.options_manager.get(option_name, default)
return default
@property
def last_result(self) -> Any:
"""Return the last result from the shared context."""
if self.shared_context:
return self.shared_context.last_result()
return None
@property
def never_prompt(self) -> bool:
return self.get_option("never_prompt", self._never_prompt)
def prepare(
self, shared_context: SharedContext, options_manager: OptionsManager | None = None
) -> BaseAction:
"""
Prepare the action specifically for sequential (ChainedAction) execution.
Can be overridden for chain-specific logic.
"""
self.set_shared_context(shared_context)
if options_manager:
self.set_options_manager(options_manager)
return self
def _maybe_inject_last_result(self, kwargs: dict[str, Any]) -> dict[str, Any]:
if self.inject_last_result and self.shared_context:
key = self.inject_into
if key in kwargs:
logger.warning("[%s] Overriding '%s' with last_result", self.name, key)
kwargs = dict(kwargs)
kwargs[key] = self.shared_context.last_result()
return kwargs
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)
async def _write_stdout(self, data: str) -> None:
"""Override in subclasses that produce terminal output."""
def __repr__(self) -> str:
return str(self)

View File

@ -0,0 +1,208 @@
from __future__ import annotations
from typing import Any, Callable
from rich.tree import Tree
from falyx.action.action import Action
from falyx.action.base import BaseAction
from falyx.action.fallback_action import FallbackAction
from falyx.action.literal_input_action import LiteralInputAction
from falyx.action.mixins import ActionListMixin
from falyx.context import ExecutionContext, SharedContext
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.themes import OneColors
class ChainedAction(BaseAction, ActionListMixin):
"""
ChainedAction executes a sequence of actions one after another.
Features:
- Supports optional automatic last_result injection (auto_inject).
- Recovers from intermediate errors using FallbackAction if present.
- Rolls back all previously executed actions if a failure occurs.
- Handles literal values with LiteralInputAction.
Best used for defining robust, ordered workflows where each step can depend on
previous results.
Args:
name (str): Name of the chain.
actions (list): List of actions or literals to execute.
hooks (HookManager, optional): Hooks for lifecycle events.
inject_last_result (bool, optional): Whether to inject last results into kwargs
by default.
inject_into (str, optional): Key name for injection.
auto_inject (bool, optional): Auto-enable injection for subsequent actions.
return_list (bool, optional): Whether to return a list of all results. False
returns the last result.
"""
def __init__(
self,
name: str,
actions: list[BaseAction | Any] | None = None,
*,
hooks: HookManager | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
auto_inject: bool = False,
return_list: bool = False,
) -> None:
super().__init__(
name,
hooks=hooks,
inject_last_result=inject_last_result,
inject_into=inject_into,
)
ActionListMixin.__init__(self)
self.auto_inject = auto_inject
self.return_list = return_list
if actions:
self.set_actions(actions)
def _wrap_if_needed(self, action: BaseAction | Any) -> BaseAction:
if isinstance(action, BaseAction):
return action
elif callable(action):
return Action(name=action.__name__, action=action)
else:
return LiteralInputAction(action)
def add_action(self, action: BaseAction | Any) -> None:
action = self._wrap_if_needed(action)
if self.actions and self.auto_inject and not action.inject_last_result:
action.inject_last_result = True
super().add_action(action)
if hasattr(action, "register_teardown") and callable(action.register_teardown):
action.register_teardown(self.hooks)
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
if self.actions:
return self.actions[0].get_infer_target()
return None, None
def _clear_args(self):
return (), {}
async def _run(self, *args, **kwargs) -> list[Any]:
if not self.actions:
raise EmptyChainError(f"[{self.name}] No actions to execute.")
shared_context = SharedContext(name=self.name, action=self)
if self.shared_context:
shared_context.add_result(self.shared_context.last_result())
updated_kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext(
name=self.name,
args=args,
kwargs=updated_kwargs,
action=self,
extra={"results": [], "rollback_stack": []},
shared_context=shared_context,
)
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
for index, action in enumerate(self.actions):
if action._skip_in_chain:
logger.debug(
"[%s] Skipping consumed action '%s'", self.name, action.name
)
continue
shared_context.current_index = index
prepared = action.prepare(shared_context, self.options_manager)
try:
result = await prepared(*args, **updated_kwargs)
except Exception as error:
if index + 1 < len(self.actions) and isinstance(
self.actions[index + 1], FallbackAction
):
logger.warning(
"[%s] Fallback triggered: %s, recovering with fallback "
"'%s'.",
self.name,
error,
self.actions[index + 1].name,
)
shared_context.add_result(None)
context.extra["results"].append(None)
fallback = self.actions[index + 1].prepare(shared_context)
result = await fallback()
fallback._skip_in_chain = True
else:
raise
args, updated_kwargs = self._clear_args()
shared_context.add_result(result)
context.extra["results"].append(result)
context.extra["rollback_stack"].append(prepared)
all_results = context.extra["results"]
assert (
all_results
), f"[{self.name}] No results captured. Something seriously went wrong."
context.result = all_results if self.return_list else all_results[-1]
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return context.result
except Exception as error:
context.exception = error
shared_context.add_error(shared_context.current_index, error)
await self._rollback(context.extra["rollback_stack"], *args, **kwargs)
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 _rollback(self, rollback_stack, *args, **kwargs):
"""
Roll back all executed actions in reverse order.
Rollbacks run even if a fallback recovered from failure,
ensuring consistent undo of all side effects.
Actions without rollback handlers are skipped.
Args:
rollback_stack (list): Actions to roll back.
*args, **kwargs: Passed to rollback handlers.
"""
for action in reversed(rollback_stack):
rollback = getattr(action, "rollback", None)
if rollback:
try:
logger.warning("[%s] Rolling back...", action.name)
await action.rollback(*args, **kwargs)
except Exception as error:
logger.error("[%s] Rollback failed: %s", action.name, error)
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)
for action in self.actions:
action.register_hooks_recursively(hook_type, hook)
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.CYAN_b}]⛓ ChainedAction[/] '{self.name}'"]
if self.inject_last_result:
label.append(f" [dim](injects '{self.inject_into}')[/dim]")
tree = parent.add("".join(label)) if parent else Tree("".join(label))
for action in self.actions:
await action.preview(parent=tree)
if not parent:
self.console.print(tree)
def __str__(self):
return (
f"ChainedAction(name={self.name!r}, "
f"actions={[a.name for a in self.actions]!r}, "
f"auto_inject={self.auto_inject}, return_list={self.return_list})"
)

View File

@ -0,0 +1,49 @@
from functools import cached_property
from typing import Any
from rich.tree import Tree
from falyx.action.action import Action
from falyx.themes import OneColors
class FallbackAction(Action):
"""
FallbackAction provides a default value if the previous action failed or
returned None.
It injects the last result and checks:
- If last_result is not None, it passes it through unchanged.
- If last_result is None (e.g., due to failure), it replaces it with a fallback value.
Used in ChainedAction pipelines to gracefully recover from errors or missing data.
When activated, it consumes the preceding error and allows the chain to continue
normally.
Args:
fallback (Any): The fallback value to use if last_result is None.
"""
def __init__(self, fallback: Any):
self._fallback = fallback
async def _fallback_logic(last_result):
return last_result if last_result is not None else fallback
super().__init__(name="Fallback", action=_fallback_logic, inject_last_result=True)
@cached_property
def fallback(self) -> Any:
"""Return the fallback value."""
return self._fallback
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.LIGHT_RED}]🛟 Fallback[/] '{self.name}'"]
label.append(f" [dim](uses fallback = {repr(self.fallback)})[/dim]")
if parent:
parent.add("".join(label))
else:
self.console.print(Tree("".join(label)))
def __str__(self) -> str:
return f"FallbackAction(fallback={self.fallback!r})"

159
falyx/action/http_action.py Normal file
View File

@ -0,0 +1,159 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""http_action.py
Defines an Action subclass for making HTTP requests using aiohttp within Falyx workflows.
Features:
- Automatic reuse of aiohttp.ClientSession via SharedContext
- JSON, query param, header, and body support
- Retry integration and last_result injection
- Clean resource teardown using hooks
"""
from typing import Any
import aiohttp
from rich.tree import Tree
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 import OneColors
async def close_shared_http_session(context: ExecutionContext) -> None:
try:
shared_context: SharedContext = context.get_shared_context()
session = shared_context.get("http_session")
should_close = shared_context.get("_session_should_close", False)
if session and should_close:
await session.close()
except Exception as error:
logger.warning("Error closing shared HTTP session: %s", error)
class HTTPAction(Action):
"""
An Action for executing HTTP requests using aiohttp with shared session reuse.
This action integrates seamlessly into Falyx pipelines, with automatic session
management, result injection, and lifecycle hook support. It is ideal for CLI-driven
API workflows where you need to call remote services and process their responses.
Features:
- Uses aiohttp for asynchronous HTTP requests
- Reuses a shared session via SharedContext to reduce connection overhead
- Automatically closes the session at the end of an ActionGroup (if applicable)
- Supports GET, POST, PUT, DELETE, etc. with full header, query, body support
- Retry and result injection compatible
Args:
name (str): Name of the action.
method (str): HTTP method (e.g., 'GET', 'POST').
url (str): The request URL.
headers (dict[str, str], optional): Request headers.
params (dict[str, Any], optional): URL query parameters.
json (dict[str, Any], optional): JSON body to send.
data (Any, optional): Raw data or form-encoded body.
hooks (HookManager, optional): Hook manager for lifecycle events.
inject_last_result (bool): Enable last_result injection.
inject_into (str): Name of injected key.
retry (bool): Enable retry logic.
retry_policy (RetryPolicy): Retry settings.
"""
def __init__(
self,
name: str,
method: str,
url: str,
*,
args: tuple[Any, ...] = (),
headers: dict[str, str] | None = None,
params: dict[str, Any] | None = None,
json: dict[str, Any] | None = None,
data: Any = None,
hooks=None,
inject_last_result: bool = False,
inject_into: str = "last_result",
retry: bool = False,
retry_policy=None,
):
self.method = method.upper()
self.url = url
self.headers = headers
self.params = params
self.json = json
self.data = data
super().__init__(
name=name,
action=self._request,
args=args,
kwargs={},
hooks=hooks,
inject_last_result=inject_last_result,
inject_into=inject_into,
retry=retry,
retry_policy=retry_policy,
)
async def _request(self, *_, **__) -> dict[str, Any]:
if self.shared_context:
context: SharedContext = self.shared_context
session = context.get("http_session")
if session is None:
session = aiohttp.ClientSession()
context.set("http_session", session)
context.set("_session_should_close", True)
else:
session = aiohttp.ClientSession()
try:
async with session.request(
self.method,
self.url,
headers=self.headers,
params=self.params,
json=self.json,
data=self.data,
) as response:
body = await response.text()
return {
"status": response.status,
"url": str(response.url),
"headers": dict(response.headers),
"body": body,
}
finally:
if not self.shared_context:
await session.close()
def register_teardown(self, hooks: HookManager):
hooks.register(HookType.ON_TEARDOWN, close_shared_http_session)
async def preview(self, parent: Tree | None = None):
label = [
f"[{OneColors.CYAN_b}]🌐 HTTPAction[/] '{self.name}'",
f"\n[dim]Method:[/] {self.method}",
f"\n[dim]URL:[/] {self.url}",
]
if self.inject_last_result:
label.append(f"\n[dim]Injects:[/] '{self.inject_into}'")
if self.retry_policy and self.retry_policy.enabled:
label.append(
f"\n[dim]↻ Retries:[/] {self.retry_policy.max_retries}x, "
f"delay {self.retry_policy.delay}s, backoff {self.retry_policy.backoff}x"
)
if parent:
parent.add("".join(label))
else:
self.console.print(Tree("".join(label)))
def __str__(self):
return (
f"HTTPAction(name={self.name!r}, method={self.method!r}, url={self.url!r}, "
f"headers={self.headers!r}, params={self.params!r}, json={self.json!r}, "
f"data={self.data!r}, retry={self.retry_policy.enabled}, "
f"inject_last_result={self.inject_last_result})"
)

265
falyx/action/io_action.py Normal file
View File

@ -0,0 +1,265 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""io_action.py
BaseIOAction: A base class for stream- or buffer-based IO-driven Actions.
This module defines `BaseIOAction`, a specialized variant of `BaseAction`
that interacts with standard input and output, enabling command-line pipelines,
text filters, and stream processing tasks.
Features:
- Supports buffered or streaming input modes.
- Reads from stdin and writes to stdout automatically.
- Integrates with lifecycle hooks and retry logic.
- Subclasses must implement `from_input()`, `to_output()`, and `_run()`.
Common usage includes shell-like filters, input transformers, or any tool that
needs to consume input from another process or pipeline.
"""
import asyncio
import shlex
import subprocess
import sys
from typing import Any, Callable
from rich.tree import Tree
from falyx.action.base 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 import OneColors
class BaseIOAction(BaseAction):
"""
Base class for IO-driven Actions that operate on stdin/stdout input streams.
Designed for use in shell pipelines or programmatic workflows that pass data
through chained commands. It handles reading input, transforming it, and
emitting output either as a one-time buffered operation or line-by-line streaming.
Core responsibilities:
- Reads input from stdin or previous action result.
- Supports buffered or streaming modes via `mode`.
- Parses input via `from_input()` and formats output via `to_output()`.
- Executes `_run()` with the parsed input.
- Writes output to stdout.
Subclasses must implement:
- `from_input(raw)`: Convert raw stdin or injected data into typed input.
- `to_output(data)`: Convert result into output string or bytes.
- `_run(parsed_input, *args, **kwargs)`: Core execution logic.
Attributes:
mode (str): Either "buffered" or "stream". Controls input behavior.
inject_last_result (bool): Whether to inject shared context input.
"""
def __init__(
self,
name: str,
*,
hooks: HookManager | None = None,
mode: str = "buffered",
logging_hooks: bool = True,
inject_last_result: bool = True,
):
super().__init__(
name=name,
hooks=hooks,
logging_hooks=logging_hooks,
inject_last_result=inject_last_result,
)
self.mode = mode
def from_input(self, raw: str | bytes) -> Any:
raise NotImplementedError
def to_output(self, result: Any) -> str | bytes:
raise NotImplementedError
async def _resolve_input(
self, args: tuple[Any], kwargs: dict[str, Any]
) -> str | bytes:
data = await self._read_stdin()
if data:
return self.from_input(data)
if len(args) == 1:
return self.from_input(args[0])
if self.inject_last_result and self.shared_context:
return self.shared_context.last_result()
logger.debug(
"[%s] No input provided and no last result found for injection.", self.name
)
raise FalyxError("No input provided and no last result to inject.")
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
return None, None
async def __call__(self, *args, **kwargs):
context = ExecutionContext(
name=self.name,
args=args,
kwargs=kwargs,
action=self,
)
context.start_timer()
await self.hooks.trigger(HookType.BEFORE, context)
try:
if self.mode == "stream":
line_gen = await self._read_stdin_stream()
async for _ in self._stream_lines(line_gen, args, kwargs):
pass
result = getattr(self, "_last_result", None)
else:
parsed_input = await self._resolve_input(args, kwargs)
result = await self._run(parsed_input)
output = self.to_output(result)
await self._write_stdout(output)
context.result = 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 _read_stdin(self) -> str:
if not sys.stdin.isatty():
return await asyncio.to_thread(sys.stdin.read)
return ""
async def _read_stdin_stream(self) -> Any:
"""Returns a generator that yields lines from stdin in a background thread."""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, lambda: iter(sys.stdin))
async def _stream_lines(self, line_gen, args, kwargs):
for line in line_gen:
parsed = self.from_input(line)
result = await self._run(parsed, *args, **kwargs)
self._last_result = result
output = self.to_output(result)
await self._write_stdout(output)
yield result
async def _write_stdout(self, data: str) -> None:
await asyncio.to_thread(sys.stdout.write, data)
await asyncio.to_thread(sys.stdout.flush)
async def _run(self, parsed_input: Any, *args, **kwargs) -> Any:
"""Subclasses should override this with actual logic."""
raise NotImplementedError("Must implement _run()")
def __str__(self):
return f"<{self.__class__.__name__} '{self.name}' IOAction>"
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.GREEN_b}]⚙ IOAction[/] '{self.name}'"]
if self.inject_last_result:
label.append(f" [dim](injects '{self.inject_into}')[/dim]")
if parent:
parent.add("".join(label))
else:
self.console.print(Tree("".join(label)))
class ShellAction(BaseIOAction):
"""
ShellAction wraps a shell command template for CLI pipelines.
This Action takes parsed input (from stdin, literal, or last_result),
substitutes it into the provided shell command template, and executes
the command asynchronously using subprocess.
Designed for quick integration with shell tools like `grep`, `ping`, `jq`, etc.
Security Warning:
By default, ShellAction uses `shell=True`, which can be dangerous with
unsanitized input. To mitigate this, set `safe_mode=True` to use `shell=False`
with `shlex.split()`.
Features:
- Automatically handles input parsing (str/bytes)
- `safe_mode=True` disables shell interpretation and runs with `shell=False`
- Captures stdout and stderr from shell execution
- Raises on non-zero exit codes with stderr as the error
- Result is returned as trimmed stdout string
Args:
name (str): Name of the action.
command_template (str): Shell command to execute. Must include `{}` to include
input. If no placeholder is present, the input is not
included.
safe_mode (bool): If True, runs with `shell=False` using shlex parsing
(default: False).
"""
def __init__(
self, name: str, command_template: str, safe_mode: bool = False, **kwargs
):
super().__init__(name=name, **kwargs)
self.command_template = command_template
self.safe_mode = safe_mode
def from_input(self, raw: str | bytes) -> str:
if not isinstance(raw, (str, bytes)):
raise TypeError(
f"{self.name} expected str or bytes input, got {type(raw).__name__}"
)
return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip()
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
if sys.stdin.isatty():
return self._run, {"parsed_input": {"help": self.command_template}}
return None, None
async def _run(self, parsed_input: str) -> str:
# Replace placeholder in template, or use raw input as full command
command = self.command_template.format(parsed_input)
if self.safe_mode:
try:
args = shlex.split(command)
except ValueError as error:
raise FalyxError(f"Invalid command template: {error}")
result = subprocess.run(args, capture_output=True, text=True, check=True)
else:
result = subprocess.run(
command, shell=True, text=True, capture_output=True, check=True
)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip())
return result.stdout.strip()
def to_output(self, result: str) -> str:
return result
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.GREEN_b}]⚙ ShellAction[/] '{self.name}'"]
label.append(f"\n[dim]Template:[/] {self.command_template}")
label.append(
f"\n[dim]Safe mode:[/] {'Enabled' if self.safe_mode else 'Disabled'}"
)
if self.inject_last_result:
label.append(f" [dim](injects '{self.inject_into}')[/dim]")
tree = parent.add("".join(label)) if parent else Tree("".join(label))
if not parent:
self.console.print(tree)
def __str__(self):
return (
f"ShellAction(name={self.name!r}, command_template={self.command_template!r},"
f" safe_mode={self.safe_mode})"
)

View File

@ -0,0 +1,47 @@
from __future__ import annotations
from functools import cached_property
from typing import Any
from rich.tree import Tree
from falyx.action.action import Action
from falyx.themes import OneColors
class LiteralInputAction(Action):
"""
LiteralInputAction injects a static value into a ChainedAction.
This allows embedding hardcoded values mid-pipeline, useful when:
- Providing default or fallback inputs.
- Starting a pipeline with a fixed input.
- Supplying missing context manually.
Args:
value (Any): The static value to inject.
"""
def __init__(self, value: Any):
self._value = value
async def literal(*_, **__):
return value
super().__init__("Input", literal)
@cached_property
def value(self) -> Any:
"""Return the literal value."""
return self._value
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.LIGHT_YELLOW}]📥 LiteralInput[/] '{self.name}'"]
label.append(f" [dim](value = {repr(self.value)})[/dim]")
if parent:
parent.add("".join(label))
else:
self.console.print(Tree("".join(label)))
def __str__(self) -> str:
return f"LiteralInputAction(value={self.value!r})"

162
falyx/action/menu_action.py Normal file
View File

@ -0,0 +1,162 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""menu_action.py"""
from typing import Any
from prompt_toolkit import PromptSession
from rich.console import Console
from rich.table import Table
from rich.tree import Tree
from falyx.action.base 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.signals import BackSignal, QuitSignal
from falyx.themes import OneColors
from falyx.utils import chunks
class MenuAction(BaseAction):
"""MenuAction class for creating single use menu actions."""
def __init__(
self,
name: str,
menu_options: MenuOptionMap,
*,
title: str = "Select an option",
columns: int = 2,
prompt_message: str = "Select > ",
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,
show_table: bool = True,
custom_table: Table | None = None,
):
super().__init__(
name,
inject_last_result=inject_last_result,
inject_into=inject_into,
never_prompt=never_prompt,
)
self.menu_options = menu_options
self.title = title
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
self.custom_table = custom_table
def _build_table(self) -> Table:
if self.custom_table:
return self.custom_table
table = render_table_base(
title=self.title,
columns=self.columns,
)
for chunk in chunks(
self.menu_options.items(include_reserved=self.include_reserved), self.columns
):
row = []
for key, option in chunk:
row.append(option.render(key))
table.add_row(*row)
return table
def get_infer_target(self) -> tuple[None, None]:
return None, None
async def _run(self, *args, **kwargs) -> Any:
kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext(
name=self.name,
args=args,
kwargs=kwargs,
action=self,
)
effective_default = self.default_selection
maybe_result = str(self.last_result)
if maybe_result in self.menu_options:
effective_default = maybe_result
elif self.inject_last_result:
logger.warning(
"[%s] Injected last result '%s' not found in menu options",
self.name,
maybe_result,
)
if self.never_prompt and not effective_default:
raise ValueError(
f"[{self.name}] 'never_prompt' is True but no valid default_selection"
" was provided."
)
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
key = effective_default
if not self.never_prompt:
table = self._build_table()
key = await prompt_for_selection(
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,
)
option = self.menu_options[key]
result = await option.action(*args, **kwargs)
context.result = result
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return result
except BackSignal:
logger.debug("[%s][BackSignal] <- Returning to previous menu", self.name)
return None
except QuitSignal:
logger.debug("[%s][QuitSignal] <- Exiting application", self.name)
raise
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.DARK_YELLOW_b}]📋 MenuAction[/] '{self.name}'"
tree = parent.add(label) if parent else Tree(label)
for key, option in self.menu_options.items():
tree.add(
f"[dim]{key}[/]: {option.description} → [italic]{option.action.name}[/]"
)
await option.action.preview(parent=tree)
if not parent:
self.console.print(tree)
def __str__(self) -> str:
return (
f"MenuAction(name={self.name!r}, options={list(self.menu_options.keys())!r}, "
f"default_selection={self.default_selection!r}, "
f"include_reserved={self.include_reserved}, "
f"prompt={'off' if self.never_prompt else 'on'})"
)

33
falyx/action/mixins.py Normal file
View File

@ -0,0 +1,33 @@
from falyx.action.base import BaseAction
class ActionListMixin:
"""Mixin for managing a list of actions."""
def __init__(self) -> None:
self.actions: list[BaseAction] = []
def set_actions(self, actions: list[BaseAction]) -> None:
"""Replaces the current action list with a new one."""
self.actions.clear()
for action in actions:
self.add_action(action)
def add_action(self, action: BaseAction) -> None:
"""Adds an action to the list."""
self.actions.append(action)
def remove_action(self, name: str) -> None:
"""Removes an action by name."""
self.actions = [action for action in self.actions if action.name != name]
def has_action(self, name: str) -> bool:
"""Checks if an action with the given name exists."""
return any(action.name == name for action in self.actions)
def get_action(self, name: str) -> BaseAction | None:
"""Retrieves an action by name."""
for action in self.actions:
if action.name == name:
return action
return None

View File

@ -0,0 +1,128 @@
from __future__ import annotations
import asyncio
from concurrent.futures import ProcessPoolExecutor
from functools import partial
from typing import Any, Callable
from rich.tree import Tree
from falyx.action.base import BaseAction
from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType
from falyx.themes import OneColors
class ProcessAction(BaseAction):
"""
ProcessAction runs a function in a separate process using ProcessPoolExecutor.
Features:
- Executes CPU-bound or blocking tasks without blocking the main event loop.
- Supports last_result injection into the subprocess.
- Validates that last_result is pickleable when injection is enabled.
Args:
name (str): Name of the action.
func (Callable): Function to execute in a new process.
args (tuple, optional): Positional arguments.
kwargs (dict, optional): Keyword arguments.
hooks (HookManager, optional): Hook manager for lifecycle events.
executor (ProcessPoolExecutor, optional): Custom executor if desired.
inject_last_result (bool, optional): Inject last result into the function.
inject_into (str, optional): Name of the injected key.
"""
def __init__(
self,
name: str,
action: Callable[..., Any],
*,
args: tuple = (),
kwargs: dict[str, Any] | None = None,
hooks: HookManager | None = None,
executor: ProcessPoolExecutor | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
):
super().__init__(
name,
hooks=hooks,
inject_last_result=inject_last_result,
inject_into=inject_into,
)
self.action = action
self.args = args
self.kwargs = kwargs or {}
self.executor = executor or ProcessPoolExecutor()
self.is_retryable = True
def get_infer_target(self) -> tuple[Callable[..., Any] | None, None]:
return self.action, None
async def _run(self, *args, **kwargs) -> Any:
if self.inject_last_result and self.shared_context:
last_result = self.shared_context.last_result()
if not self._validate_pickleable(last_result):
raise ValueError(
f"Cannot inject last result into {self.name}: "
f"last result is not pickleable."
)
combined_args = args + self.args
combined_kwargs = self._maybe_inject_last_result({**self.kwargs, **kwargs})
context = ExecutionContext(
name=self.name,
args=combined_args,
kwargs=combined_kwargs,
action=self,
)
loop = asyncio.get_running_loop()
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
result = await loop.run_in_executor(
self.executor, partial(self.action, *combined_args, **combined_kwargs)
)
context.result = 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)
if context.result is not None:
return context.result
raise
finally:
context.stop_timer()
await self.hooks.trigger(HookType.AFTER, context)
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
er.record(context)
def _validate_pickleable(self, obj: Any) -> bool:
try:
import pickle
pickle.dumps(obj)
return True
except (pickle.PicklingError, TypeError):
return False
async def preview(self, parent: Tree | None = None):
label = [
f"[{OneColors.DARK_YELLOW_b}]🧠 ProcessAction (new process)[/] '{self.name}'"
]
if self.inject_last_result:
label.append(f" [dim](injects '{self.inject_into}')[/dim]")
if parent:
parent.add("".join(label))
else:
self.console.print(Tree("".join(label)))
def __str__(self) -> str:
return (
f"ProcessAction(name={self.name!r}, "
f"action={getattr(self.action, '__name__', repr(self.action))}, "
f"args={self.args!r}, kwargs={self.kwargs!r})"
)

View File

@ -0,0 +1,166 @@
from __future__ import annotations
import asyncio
import random
from concurrent.futures import ProcessPoolExecutor
from dataclasses import dataclass, field
from functools import partial
from typing import Any, Callable
from rich.tree import Tree
from falyx.action.base import BaseAction
from falyx.context import ExecutionContext, SharedContext
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType
from falyx.logger import logger
from falyx.parsers.utils import same_argument_definitions
from falyx.themes import OneColors
@dataclass
class ProcessTask:
task: Callable[..., Any]
args: tuple = ()
kwargs: dict[str, Any] = field(default_factory=dict)
def __post_init__(self):
if not callable(self.task):
raise TypeError(f"Expected a callable task, got {type(self.task).__name__}")
class ProcessPoolAction(BaseAction):
""" """
def __init__(
self,
name: str,
actions: list[ProcessTask] | None = None,
*,
hooks: HookManager | None = None,
executor: ProcessPoolExecutor | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
):
super().__init__(
name,
hooks=hooks,
inject_last_result=inject_last_result,
inject_into=inject_into,
)
self.executor = executor or ProcessPoolExecutor()
self.is_retryable = True
self.actions: list[ProcessTask] = []
if actions:
self.set_actions(actions)
def set_actions(self, actions: list[ProcessTask]) -> None:
"""Replaces the current action list with a new one."""
self.actions.clear()
for action in actions:
self.add_action(action)
def add_action(self, action: ProcessTask) -> None:
if not isinstance(action, ProcessTask):
raise TypeError(f"Expected a ProcessTask, got {type(action).__name__}")
self.actions.append(action)
def get_infer_target(self) -> tuple[Callable[..., Any] | None, None]:
arg_defs = same_argument_definitions([action.task for action in self.actions])
if arg_defs:
return self.actions[0].task, None
logger.debug(
"[%s] auto_args disabled: mismatched ProcessPoolAction arguments",
self.name,
)
return None, None
async def _run(self, *args, **kwargs) -> Any:
shared_context = SharedContext(name=self.name, action=self, is_parallel=True)
if self.shared_context:
shared_context.set_shared_result(self.shared_context.last_result())
if self.inject_last_result and self.shared_context:
last_result = self.shared_context.last_result()
if not self._validate_pickleable(last_result):
raise ValueError(
f"Cannot inject last result into {self.name}: "
f"last result is not pickleable."
)
print(kwargs)
updated_kwargs = self._maybe_inject_last_result(kwargs)
print(updated_kwargs)
context = ExecutionContext(
name=self.name,
args=args,
kwargs=updated_kwargs,
action=self,
)
loop = asyncio.get_running_loop()
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
futures = [
loop.run_in_executor(
self.executor,
partial(
task.task,
*(*args, *task.args),
**{**updated_kwargs, **task.kwargs},
),
)
for task in self.actions
]
results = await asyncio.gather(*futures, return_exceptions=True)
context.result = results
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return results
except Exception as error:
context.exception = error
await self.hooks.trigger(HookType.ON_ERROR, context)
if context.result is not None:
return context.result
raise
finally:
context.stop_timer()
await self.hooks.trigger(HookType.AFTER, context)
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
er.record(context)
def _validate_pickleable(self, obj: Any) -> bool:
try:
import pickle
pickle.dumps(obj)
return True
except (pickle.PicklingError, TypeError):
return False
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.DARK_YELLOW_b}]🧠 ProcessPoolAction[/] '{self.name}'"]
if self.inject_last_result:
label.append(f" [dim](receives '{self.inject_into}')[/dim]")
tree = parent.add("".join(label)) if parent else Tree("".join(label))
actions = self.actions.copy()
random.shuffle(actions)
for action in actions:
label = [
f"[{OneColors.DARK_YELLOW_b}] - {getattr(action.task, '__name__', repr(action.task))}[/] "
f"[dim]({', '.join(map(repr, action.args))})[/]"
]
if action.kwargs:
label.append(
f" [dim]({', '.join(f'{k}={v!r}' for k, v in action.kwargs.items())})[/]"
)
tree.add("".join(label))
if not parent:
self.console.print(tree)
def __str__(self) -> str:
return (
f"ProcessPoolAction(name={self.name!r}, "
f"actions={[getattr(action.task, '__name__', repr(action.task)) for action in self.actions]}, "
f"inject_last_result={self.inject_last_result}, "
f"inject_into={self.inject_into!r})"
)

View File

@ -0,0 +1,137 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""prompt_menu_action.py"""
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 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.signals import BackSignal, QuitSignal
from falyx.themes import OneColors
class PromptMenuAction(BaseAction):
"""PromptMenuAction class for creating prompt -> actions."""
def __init__(
self,
name: str,
menu_options: MenuOptionMap,
*,
prompt_message: str = "Select > ",
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,
):
super().__init__(
name,
inject_last_result=inject_last_result,
inject_into=inject_into,
never_prompt=never_prompt,
)
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
def get_infer_target(self) -> tuple[None, None]:
return None, None
async def _run(self, *args, **kwargs) -> Any:
kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext(
name=self.name,
args=args,
kwargs=kwargs,
action=self,
)
effective_default = self.default_selection
maybe_result = str(self.last_result)
if maybe_result in self.menu_options:
effective_default = maybe_result
elif self.inject_last_result:
logger.warning(
"[%s] Injected last result '%s' not found in menu options",
self.name,
maybe_result,
)
if self.never_prompt and not effective_default:
raise ValueError(
f"[{self.name}] 'never_prompt' is True but no valid default_selection"
" was provided."
)
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
key = effective_default
if not self.never_prompt:
placeholder_formatted_text = []
for index, (key, option) in enumerate(self.menu_options.items()):
placeholder_formatted_text.append(option.render_prompt(key))
if index < len(self.menu_options) - 1:
placeholder_formatted_text.append(
FormattedText([(OneColors.WHITE, " | ")])
)
placeholder = merge_formatted_text(placeholder_formatted_text)
key = await self.prompt_session.prompt_async(
message=self.prompt_message, placeholder=placeholder
)
option = self.menu_options[key]
result = await option.action(*args, **kwargs)
context.result = result
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return result
except BackSignal:
logger.debug("[%s][BackSignal] ← Returning to previous menu", self.name)
return None
except QuitSignal:
logger.debug("[%s][QuitSignal] ← Exiting application", self.name)
raise
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.LIGHT_YELLOW_b}]📋 PromptMenuAction[/] '{self.name}'"
tree = parent.add(label) if parent else Tree(label)
for key, option in self.menu_options.items():
tree.add(
f"[dim]{key}[/]: {option.description} → [italic]{option.action.name}[/]"
)
await option.action.preview(parent=tree)
if not parent:
self.console.print(tree)
def __str__(self) -> str:
return (
f"PromptMenuAction(name={self.name!r}, options={list(self.menu_options.keys())!r}, "
f"default_selection={self.default_selection!r}, "
f"include_reserved={self.include_reserved}, "
f"prompt={'off' if self.never_prompt else 'on'})"
)

View File

@ -0,0 +1,220 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""select_file_action.py"""
from __future__ import annotations
import csv
import json
import xml.etree.ElementTree as ET
from pathlib import Path
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.base 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
from falyx.logger import logger
from falyx.selection import (
SelectionOption,
prompt_for_selection,
render_selection_dict_table,
)
from falyx.signals import CancelSignal
from falyx.themes import OneColors
class SelectFileAction(BaseAction):
"""
SelectFileAction allows users to select a file from a directory and return:
- file content (as text, JSON, CSV, etc.)
- or the file path itself.
Supported formats: text, json, yaml, toml, csv, tsv, xml.
Useful for:
- dynamically loading config files
- interacting with user-selected data
- chaining file contents into workflows
Args:
name (str): Name of the action.
directory (Path | str): Where to search for files.
title (str): Title of the selection menu.
columns (int): Number of columns in the selection menu.
prompt_message (str): Message to display when prompting for selection.
style (str): Style for the selection options.
suffix_filter (str | None): Restrict to certain file types.
return_type (FileReturnType): What to return (path, content, parsed).
console (Console | None): Console instance for output.
prompt_session (PromptSession | None): Prompt session for user input.
"""
def __init__(
self,
name: str,
directory: Path | str = ".",
*,
title: str = "Select a file",
columns: int = 3,
prompt_message: str = "Choose > ",
style: str = OneColors.WHITE,
suffix_filter: str | None = None,
return_type: FileReturnType | str = FileReturnType.PATH,
console: Console | None = None,
prompt_session: PromptSession | None = None,
):
super().__init__(name)
self.directory = Path(directory).resolve()
self.title = title
self.columns = columns
self.prompt_message = prompt_message
self.suffix_filter = suffix_filter
self.style = style
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)
def _coerce_return_type(self, return_type: FileReturnType | str) -> FileReturnType:
if isinstance(return_type, FileReturnType):
return return_type
return FileReturnType(return_type)
def get_options(self, files: list[Path]) -> dict[str, SelectionOption]:
value: Any
options = {}
for index, file in enumerate(files):
try:
if self.return_type == FileReturnType.TEXT:
value = file.read_text(encoding="UTF-8")
elif self.return_type == FileReturnType.PATH:
value = file
elif self.return_type == FileReturnType.JSON:
value = json.loads(file.read_text(encoding="UTF-8"))
elif self.return_type == FileReturnType.TOML:
value = toml.loads(file.read_text(encoding="UTF-8"))
elif self.return_type == FileReturnType.YAML:
value = yaml.safe_load(file.read_text(encoding="UTF-8"))
elif self.return_type == FileReturnType.CSV:
with open(file, newline="", encoding="UTF-8") as csvfile:
reader = csv.reader(csvfile)
value = list(reader)
elif self.return_type == FileReturnType.TSV:
with open(file, newline="", encoding="UTF-8") as tsvfile:
reader = csv.reader(tsvfile, delimiter="\t")
value = list(reader)
elif self.return_type == FileReturnType.XML:
tree = ET.parse(file, parser=ET.XMLParser(encoding="UTF-8"))
root = tree.getroot()
value = ET.tostring(root, encoding="unicode")
else:
raise ValueError(f"Unsupported return type: {self.return_type}")
options[str(index)] = SelectionOption(
description=file.name, value=value, style=self.style
)
except Exception as error:
logger.error("Failed to parse %s: %s", file.name, error)
return options
def _find_cancel_key(self, options) -> str:
"""Return first numeric value not already used in the selection dict."""
for index in range(len(options)):
if str(index) not in options:
return str(index)
return str(len(options))
def get_infer_target(self) -> tuple[None, None]:
return None, None
async def _run(self, *args, **kwargs) -> Any:
context = ExecutionContext(name=self.name, args=args, kwargs=kwargs, action=self)
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
files = [
file
for file in self.directory.iterdir()
if file.is_file()
and (self.suffix_filter is None or file.suffix == self.suffix_filter)
]
if not files:
raise FileNotFoundError("No files found in directory.")
options = self.get_options(files)
cancel_key = self._find_cancel_key(options)
cancel_option = {
cancel_key: SelectionOption(
description="Cancel", value=CancelSignal(), style=OneColors.DARK_RED
)
}
table = render_selection_dict_table(
title=self.title, selections=options | cancel_option, columns=self.columns
)
key = await prompt_for_selection(
(options | cancel_option).keys(),
table,
console=self.console,
prompt_session=self.prompt_session,
prompt_message=self.prompt_message,
)
if key == cancel_key:
raise CancelSignal("User canceled the selection.")
result = options[key].value
context.result = 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):
label = f"[{OneColors.GREEN}]📁 SelectFilesAction[/] '{self.name}'"
tree = parent.add(label) if parent else Tree(label)
tree.add(f"[dim]Directory:[/] {str(self.directory)}")
tree.add(f"[dim]Suffix filter:[/] {self.suffix_filter or 'None'}")
tree.add(f"[dim]Return type:[/] {self.return_type}")
tree.add(f"[dim]Prompt:[/] {self.prompt_message}")
tree.add(f"[dim]Columns:[/] {self.columns}")
try:
files = list(self.directory.iterdir())
if self.suffix_filter:
files = [file for file in files if file.suffix == self.suffix_filter]
sample = files[:10]
file_list = tree.add("[dim]Files:[/]")
for file in sample:
file_list.add(f"[dim]{file.name}[/]")
if len(files) > 10:
file_list.add(f"[dim]... ({len(files) - 10} more)[/]")
except Exception as error:
tree.add(f"[{OneColors.DARK_RED_b}]⚠️ Error scanning directory: {error}[/]")
if not parent:
self.console.print(tree)
def __str__(self) -> str:
return (
f"SelectFilesAction(name={self.name!r}, dir={str(self.directory)!r}, "
f"suffix_filter={self.suffix_filter!r}, return_type={self.return_type})"
)

View File

@ -0,0 +1,320 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""selection_action.py"""
from typing import Any
from prompt_toolkit import PromptSession
from rich.console import Console
from rich.tree import Tree
from falyx.action.base import BaseAction
from falyx.action.types import SelectionReturnType
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.selection import (
SelectionOption,
SelectionOptionMap,
prompt_for_index,
prompt_for_selection,
render_selection_dict_table,
render_selection_indexed_table,
)
from falyx.signals import CancelSignal
from falyx.themes import OneColors
class SelectionAction(BaseAction):
"""
A selection action that prompts the user to select an option from a list or
dictionary. The selected option is then returned as the result of the action.
If return_key is True, the key of the selected option is returned instead of
the value.
"""
def __init__(
self,
name: str,
selections: (
list[str]
| set[str]
| tuple[str, ...]
| dict[str, SelectionOption]
| dict[str, Any]
),
*,
title: str = "Select an option",
columns: int = 5,
prompt_message: str = "Select > ",
default_selection: str = "",
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,
):
super().__init__(
name,
inject_last_result=inject_last_result,
inject_into=inject_into,
never_prompt=never_prompt,
)
# Setter normalizes to correct type, mypy can't infer that
self.selections: list[str] | SelectionOptionMap = selections # type: ignore[assignment]
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.prompt_message = prompt_message
self.show_table = show_table
self.cancel_key = self._find_cancel_key()
def _coerce_return_type(
self, return_type: SelectionReturnType | str
) -> SelectionReturnType:
if isinstance(return_type, SelectionReturnType):
return return_type
return SelectionReturnType(return_type)
@property
def selections(self) -> list[str] | SelectionOptionMap:
return self._selections
@selections.setter
def selections(
self, value: list[str] | set[str] | tuple[str, ...] | dict[str, SelectionOption]
):
if isinstance(value, (list, tuple, set)):
self._selections: list[str] | SelectionOptionMap = list(value)
elif isinstance(value, dict):
som = SelectionOptionMap()
if all(isinstance(key, str) for key in value) and all(
not isinstance(value[key], SelectionOption) for key in value
):
som.update(
{
str(index): SelectionOption(key, option)
for index, (key, option) in enumerate(value.items())
}
)
elif all(isinstance(key, str) for key in value) and all(
isinstance(value[key], SelectionOption) for key in value
):
som.update(value)
else:
raise ValueError("Invalid dictionary format. Keys must be strings")
self._selections = som
else:
raise TypeError(
"'selections' must be a list[str] or dict[str, SelectionOption], "
f"got {type(value).__name__}"
)
def _find_cancel_key(self) -> str:
"""Find the cancel key in the selections."""
if isinstance(self.selections, dict):
for index in range(len(self.selections) + 1):
if str(index) not in self.selections:
return str(index)
return str(len(self.selections))
@property
def cancel_key(self) -> str:
return self._cancel_key
@cancel_key.setter
def cancel_key(self, value: str) -> None:
"""Set the cancel key for the selection."""
if not isinstance(value, str):
raise TypeError("Cancel key must be a string.")
if isinstance(self.selections, dict) and value in self.selections:
raise ValueError(
"Cancel key cannot be one of the selection keys. "
f"Current selections: {self.selections}"
)
if isinstance(self.selections, list):
if not value.isdigit() or int(value) > len(self.selections):
raise ValueError(
"cancel_key must be a digit and not greater than the number of selections."
)
self._cancel_key = value
def cancel_formatter(self, index: int, selection: str) -> str:
"""Format the cancel option for display."""
if self.cancel_key == str(index):
return f"[{index}] [{OneColors.DARK_RED}]Cancel[/]"
return f"[{index}] {selection}"
def get_infer_target(self) -> tuple[None, None]:
return None, None
async def _run(self, *args, **kwargs) -> Any:
kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext(
name=self.name,
args=args,
kwargs=kwargs,
action=self,
)
effective_default = str(self.default_selection)
maybe_result = str(self.last_result)
if isinstance(self.selections, dict):
if maybe_result in self.selections:
effective_default = maybe_result
elif self.inject_last_result:
logger.warning(
"[%s] Injected last result '%s' not found in selections",
self.name,
maybe_result,
)
elif isinstance(self.selections, list):
if maybe_result.isdigit() and int(maybe_result) in range(
len(self.selections)
):
effective_default = maybe_result
elif self.inject_last_result:
logger.warning(
"[%s] Injected last result '%s' not found in selections",
self.name,
maybe_result,
)
if self.never_prompt and not effective_default:
raise ValueError(
f"[{self.name}] 'never_prompt' is True but no valid default_selection "
"was provided."
)
context.start_timer()
try:
self.cancel_key = self._find_cancel_key()
await self.hooks.trigger(HookType.BEFORE, context)
if isinstance(self.selections, list):
table = render_selection_indexed_table(
title=self.title,
selections=self.selections + ["Cancel"],
columns=self.columns,
formatter=self.cancel_formatter,
)
if not self.never_prompt:
index: int | str = await prompt_for_index(
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,
)
else:
index = effective_default
if int(index) == int(self.cancel_key):
raise CancelSignal("User cancelled the selection.")
result: Any = self.selections[int(index)]
elif isinstance(self.selections, dict):
cancel_option = {
self.cancel_key: SelectionOption(
description="Cancel", value=CancelSignal, style=OneColors.DARK_RED
)
}
table = render_selection_dict_table(
title=self.title,
selections=self.selections | cancel_option,
columns=self.columns,
)
if not self.never_prompt:
key = await prompt_for_selection(
(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,
)
else:
key = effective_default
if key == self.cancel_key:
raise CancelSignal("User cancelled the selection.")
if self.return_type == SelectionReturnType.KEY:
result = key
elif self.return_type == SelectionReturnType.VALUE:
result = self.selections[key].value
elif self.return_type == SelectionReturnType.ITEMS:
result = {key: self.selections[key]}
elif self.return_type == SelectionReturnType.DESCRIPTION:
result = self.selections[key].description
elif self.return_type == SelectionReturnType.DESCRIPTION_VALUE:
result = {
self.selections[key].description: self.selections[key].value
}
else:
raise ValueError(f"Unsupported return type: {self.return_type}")
else:
raise TypeError(
"'selections' must be a list[str] or dict[str, Any], "
f"got {type(self.selections).__name__}"
)
context.result = 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):
label = f"[{OneColors.LIGHT_RED}]🧭 SelectionAction[/] '{self.name}'"
tree = parent.add(label) if parent else Tree(label)
if isinstance(self.selections, list):
sub = tree.add(f"[dim]Type:[/] List[str] ({len(self.selections)} items)")
for i, item in enumerate(self.selections[:10]): # limit to 10
sub.add(f"[dim]{i}[/]: {item}")
if len(self.selections) > 10:
sub.add(f"[dim]... ({len(self.selections) - 10} more)[/]")
elif isinstance(self.selections, dict):
sub = tree.add(
f"[dim]Type:[/] Dict[str, (str, Any)] ({len(self.selections)} items)"
)
for i, (key, option) in enumerate(list(self.selections.items())[:10]):
sub.add(f"[dim]{key}[/]: {option.description}")
if len(self.selections) > 10:
sub.add(f"[dim]... ({len(self.selections) - 10} more)[/]")
else:
tree.add(f"[{OneColors.DARK_RED_b}]Invalid selections type[/]")
return
tree.add(f"[dim]Default:[/] '{self.default_selection or self.last_result}'")
tree.add(f"[dim]Return:[/] {self.return_type.name.capitalize()}")
tree.add(f"[dim]Prompt:[/] {'Disabled' if self.never_prompt else 'Enabled'}")
if not parent:
self.console.print(tree)
def __str__(self) -> str:
selection_type = (
"List"
if isinstance(self.selections, list)
else "Dict" if isinstance(self.selections, dict) else "Unknown"
)
return (
f"SelectionAction(name={self.name!r}, type={selection_type}, "
f"default_selection={self.default_selection!r}, "
f"return_type={self.return_type!r}, "
f"prompt={'off' if self.never_prompt else 'on'})"
)

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)

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

@ -0,0 +1,52 @@
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}")
class SelectionReturnType(Enum):
"""Enum for dictionary return types."""
KEY = "key"
VALUE = "value"
DESCRIPTION = "description"
DESCRIPTION_VALUE = "description_value"
ITEMS = "items"
@classmethod
def _missing_(cls, value: object) -> SelectionReturnType:
valid = ", ".join(member.value for member in cls)
raise ValueError(f"Invalid DictReturnType: '{value}'. Must be one of: {valid}")

View File

@ -0,0 +1,100 @@
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 import BaseAction
from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType
from falyx.themes.colors import OneColors
class UserInputAction(BaseAction):
"""
Prompts the user for input via PromptSession and returns the result.
Args:
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').
"""
def __init__(
self,
name: str,
*,
prompt_text: str = "Input > ",
validator: Validator | None = None,
console: Console | None = None,
prompt_session: PromptSession | None = None,
inject_last_result: bool = False,
):
super().__init__(
name=name,
inject_last_result=inject_last_result,
)
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()
def get_infer_target(self) -> tuple[None, None]:
return None, None
async def _run(self, *args, **kwargs) -> str:
context = ExecutionContext(
name=self.name,
args=args,
kwargs=kwargs,
action=self,
)
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
prompt_text = self.prompt_text
if self.inject_last_result and self.last_result:
prompt_text = prompt_text.format(last_result=self.last_result)
answer = await self.prompt_session.prompt_async(
prompt_text,
validator=self.validator,
)
context.result = answer
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return answer
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.MAGENTA}]⌨ UserInputAction[/] '{self.name}'"
tree = parent.add(label) if parent else Tree(label)
prompt_text = (
self.prompt_text.replace("{last_result}", "<last_result>")
if "{last_result}" in self.prompt_text
else self.prompt_text
)
tree.add(f"[dim]Prompt:[/] {prompt_text}")
if self.validator:
tree.add("[dim]Validator:[/] Yes")
if not parent:
self.console.print(tree)
def __str__(self):
return f"UserInputAction(name={self.name!r}, prompt={self.prompt!r})"

View File

@ -1,3 +1,4 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""bottom_bar.py""" """bottom_bar.py"""
from typing import Any, Callable from typing import Any, Callable
@ -7,8 +8,8 @@ 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 from falyx.utils import CaseInsensitiveDict, chunks
class BottomBar: class BottomBar:
@ -29,8 +30,7 @@ class BottomBar:
key_validator: Callable[[str], bool] | None = None, key_validator: Callable[[str], bool] | None = None,
) -> None: ) -> None:
self.columns = columns self.columns = columns
self.console = Console() self.console = Console(color_system="truecolor")
self._items: list[Callable[[], HTML]] = []
self._named_items: dict[str, Callable[[], HTML]] = {} self._named_items: dict[str, Callable[[], HTML]] = {}
self._value_getters: dict[str, Callable[[], Any]] = CaseInsensitiveDict() self._value_getters: dict[str, Callable[[], Any]] = CaseInsensitiveDict()
self.toggle_keys: list[str] = [] self.toggle_keys: list[str] = []
@ -45,11 +45,7 @@ class BottomBar:
def space(self) -> int: def space(self) -> int:
return self.console.width // self.columns return self.console.width // self.columns
def add_custom( def add_custom(self, name: str, render_fn: Callable[[], HTML]) -> None:
self,
name: str,
render_fn: Callable[[], HTML]
) -> None:
"""Add a custom render function to the bottom bar.""" """Add a custom render function to the bottom bar."""
if not callable(render_fn): if not callable(render_fn):
raise ValueError("`render_fn` must be callable") raise ValueError("`render_fn` must be callable")
@ -63,9 +59,7 @@ class BottomBar:
bg: str = OneColors.WHITE, bg: str = OneColors.WHITE,
) -> None: ) -> None:
def render(): def render():
return HTML( return HTML(f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>")
f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>"
)
self._add_named(name, render) self._add_named(name, render)
@ -85,9 +79,7 @@ class BottomBar:
get_value_ = self._value_getters[name] get_value_ = self._value_getters[name]
current_ = get_value_() current_ = get_value_()
text = f"{label}: {current_}" text = f"{label}: {current_}"
return HTML( return HTML(f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>")
f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>"
)
self._add_named(name, render) self._add_named(name, render)
@ -99,6 +91,7 @@ class BottomBar:
total: int, total: int,
fg: str = OneColors.BLACK, fg: str = OneColors.BLACK,
bg: str = OneColors.WHITE, bg: str = OneColors.WHITE,
enforce_total: bool = True,
) -> None: ) -> None:
if not callable(get_current): if not callable(get_current):
raise ValueError("`get_current` must be a callable returning int") raise ValueError("`get_current` must be a callable returning int")
@ -108,14 +101,12 @@ class BottomBar:
def render(): def render():
get_current_ = self._value_getters[name] get_current_ = self._value_getters[name]
current_value = get_current_() current_value = get_current_()
if current_value > total: if current_value > total and enforce_total:
raise ValueError( raise ValueError(
f"Current value {current_value} is greater than total value {total}" f"Current value {current_value} is greater than total value {total}"
) )
text = f"{label}: {current_value}/{total}" text = f"{label}: {current_value}/{total}"
return HTML( return HTML(f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>")
f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>"
)
self._add_named(name, render) self._add_named(name, render)
@ -137,7 +128,9 @@ class BottomBar:
if key in self.toggle_keys: if key in self.toggle_keys:
raise ValueError(f"Key {key} is already used as a toggle") raise ValueError(f"Key {key} is already used as a toggle")
if self.key_validator and not self.key_validator(key): if self.key_validator and not self.key_validator(key):
raise ValueError(f"Key '{key}' conflicts with existing command, toggle, or reserved key.") raise ValueError(
f"Key '{key}' conflicts with existing command, toggle, or reserved key."
)
self._value_getters[key] = get_state self._value_getters[key] = get_state
self.toggle_keys.append(key) self.toggle_keys.append(key)
@ -146,16 +139,14 @@ class BottomBar:
color = bg_on if get_state_() else bg_off color = bg_on if get_state_() else bg_off
status = "ON" if get_state_() else "OFF" status = "ON" if get_state_() else "OFF"
text = f"({key.upper()}) {label}: {status}" text = f"({key.upper()}) {label}: {status}"
return HTML( return HTML(f"<style bg='{color}' fg='{fg}'>{text:^{self.space}}</style>")
f"<style bg='{color}' fg='{fg}'>{text:^{self.space}}</style>"
)
self._add_named(key, render) self._add_named(key, render)
for k in (key.upper(), key.lower()): for k in (key.upper(), key.lower()):
@self.key_bindings.add(k) @self.key_bindings.add(k)
def _(event): def _(_):
toggle_state() toggle_state()
def add_toggle_from_option( def add_toggle_from_option(
@ -169,6 +160,7 @@ class BottomBar:
bg_on: str = OneColors.GREEN, bg_on: str = OneColors.GREEN,
bg_off: str = OneColors.DARK_RED, bg_off: str = OneColors.DARK_RED,
) -> None: ) -> None:
"""Add a toggle to the bottom bar based on an option from OptionsManager."""
self.add_toggle( self.add_toggle(
key=key, key=key,
label=label, label=label,
@ -185,15 +177,33 @@ class BottomBar:
return {label: getter() for label, getter in self._value_getters.items()} return {label: getter() for label, getter in self._value_getters.items()}
def get_value(self, name: str) -> Any: def get_value(self, name: str) -> Any:
"""Get the current value of a registered item."""
if name not in self._value_getters: if name not in self._value_getters:
raise ValueError(f"No value getter registered under name: '{name}'") raise ValueError(f"No value getter registered under name: '{name}'")
return self._value_getters[name]() return self._value_getters[name]()
def remove_item(self, name: str) -> None:
"""Remove an item from the bottom bar."""
self._named_items.pop(name, None)
self._value_getters.pop(name, None)
if name in self.toggle_keys:
self.toggle_keys.remove(name)
def clear(self) -> None:
"""Clear all items from the bottom bar."""
self._value_getters.clear()
self._named_items.clear()
self.toggle_keys.clear()
def _add_named(self, name: str, render_fn: Callable[[], HTML]) -> None: def _add_named(self, name: str, render_fn: Callable[[], HTML]) -> None:
if name in self._named_items: if name in self._named_items:
raise ValueError(f"Bottom bar item '{name}' already exists") raise ValueError(f"Bottom bar item '{name}' already exists")
self._named_items[name] = render_fn self._named_items[name] = render_fn
self._items = list(self._named_items.values())
def render(self): def render(self):
return merge_formatted_text([fn() for fn in self._items]) """Render the bottom bar."""
lines = []
for chunk in chunks(self._named_items.values(), self.columns):
lines.extend(list(chunk))
lines.append(lambda: HTML("\n"))
return merge_formatted_text([fn() for fn in lines[:-1]])

View File

@ -1,14 +1,24 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""command.py """command.py
Any Action or Command is callable and supports the signature:
result = thing(*args, **kwargs)
This guarantees: Defines the Command class for Falyx CLI.
- Hook lifecycle (before/after/error/teardown)
- Timing Commands are callable units representing a menu option or CLI task,
- Consistent return values wrapping either a BaseAction or a simple function. They provide:
- Hook lifecycle (before, on_success, on_error, after, on_teardown)
- Execution timing and duration tracking
- Retry logic (single action or recursively through action trees)
- Confirmation prompts and spinner integration
- Result capturing and summary logging
- Rich-based preview for CLI display
Every Command is self-contained, configurable, and plays a critical role
in building robust interactive menus.
""" """
from __future__ import annotations from __future__ import annotations
import shlex
from typing import Any, Callable from typing import Any, Callable
from prompt_toolkit.formatted_text import FormattedText from prompt_toolkit.formatted_text import FormattedText
@ -16,28 +26,93 @@ 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, BaseAction from falyx.action.action import Action
from falyx.action.base import BaseAction
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.debug import register_debug_hooks from falyx.debug import register_debug_hooks
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType from falyx.hook_manager import HookManager, HookType
from falyx.logger import logger
from falyx.options_manager import OptionsManager
from falyx.parsers.argparse import CommandArgumentParser
from falyx.parsers.signature import infer_args_from_func
from falyx.prompt_utils import confirm_async, should_prompt_user
from falyx.protocols import ArgParserProtocol
from falyx.retry import RetryPolicy from falyx.retry import RetryPolicy
from falyx.themes.colors import OneColors from falyx.retry_utils import enable_retries_recursively
from falyx.utils import _noop, ensure_async, logger from falyx.signals import CancelSignal
from falyx.themes import OneColors
from falyx.utils import ensure_async
console = Console() console = Console(color_system="truecolor")
class Command(BaseModel): class Command(BaseModel):
"""Class representing an command in the menu.""" """
Represents a selectable command in a Falyx menu system.
A Command wraps an executable action (function, coroutine, or BaseAction)
and enhances it with:
- Lifecycle hooks (before, success, error, after, teardown)
- Retry support (single action or recursive for chained/grouped actions)
- Confirmation prompts for safe execution
- Spinner visuals during execution
- Tagging for categorization and filtering
- Rich-based CLI previews
- Result tracking and summary reporting
Commands are built to be flexible yet robust, enabling dynamic CLI workflows
without sacrificing control or reliability.
Attributes:
key (str): Primary trigger key for the command.
description (str): Short description for the menu display.
hidden (bool): Toggles visibility in the menu.
aliases (list[str]): Alternate keys or phrases.
action (BaseAction | Callable): The executable logic.
args (tuple): Static positional arguments.
kwargs (dict): Static keyword arguments.
help_text (str): Additional help or guidance text.
style (str): Rich style for description.
confirm (bool): Whether to require confirmation before executing.
confirm_message (str): Custom confirmation prompt.
preview_before_confirm (bool): Whether to preview before confirming.
spinner (bool): Whether to show a spinner during execution.
spinner_message (str): Spinner text message.
spinner_type (str): Spinner style (e.g., dots, line, etc.).
spinner_style (str): Color or style of the spinner.
spinner_kwargs (dict): Extra spinner configuration.
hooks (HookManager): Hook manager for lifecycle events.
retry (bool): Enable retry on failure.
retry_all (bool): Enable retry across chained or grouped actions.
retry_policy (RetryPolicy): Retry behavior configuration.
tags (list[str]): Organizational tags for the command.
logging_hooks (bool): Whether to attach logging hooks automatically.
options_manager (OptionsManager): Manages global command-line options.
arg_parser (CommandArgumentParser): Parses command arguments.
custom_parser (ArgParserProtocol | None): Custom argument parser.
custom_help (Callable[[], str | None] | None): Custom help message generator.
auto_args (bool): Automatically infer arguments from the action.
Methods:
__call__(): Executes the command, respecting hooks and retries.
preview(): Rich tree preview of the command.
confirmation_prompt(): Formatted prompt for confirmation.
result: Property exposing the last result.
log_summary(): Summarizes execution details to the console.
"""
key: str key: str
description: str description: str
aliases: list[str] = Field(default_factory=list) action: BaseAction | Callable[..., Any]
action: BaseAction | Callable[[], Any] = _noop
args: tuple = () args: tuple = ()
kwargs: dict[str, Any] = Field(default_factory=dict) kwargs: dict[str, Any] = Field(default_factory=dict)
hidden: bool = False
aliases: list[str] = Field(default_factory=list)
help_text: str = "" help_text: str = ""
color: str = OneColors.WHITE help_epilog: str = ""
style: str = OneColors.WHITE
confirm: bool = False confirm: bool = False
confirm_message: str = "Are you sure?" confirm_message: str = "Are you sure?"
preview_before_confirm: bool = True preview_before_confirm: bool = True
@ -52,24 +127,55 @@ class Command(BaseModel):
retry_policy: RetryPolicy = Field(default_factory=RetryPolicy) retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
tags: list[str] = Field(default_factory=list) tags: list[str] = Field(default_factory=list)
logging_hooks: bool = False logging_hooks: bool = False
options_manager: OptionsManager = Field(default_factory=OptionsManager)
arg_parser: CommandArgumentParser | None = None
arguments: list[dict[str, Any]] = Field(default_factory=list)
argument_config: Callable[[CommandArgumentParser], None] | None = None
custom_parser: ArgParserProtocol | None = None
custom_help: Callable[[], str | None] | None = None
auto_args: bool = True
arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict)
simple_help_signature: bool = False
_context: ExecutionContext | None = PrivateAttr(default=None) _context: ExecutionContext | None = PrivateAttr(default=None)
model_config = ConfigDict(arbitrary_types_allowed=True) model_config = ConfigDict(arbitrary_types_allowed=True)
def model_post_init(self, __context: Any) -> None: async def parse_args(
"""Post-initialization to set up the action and hooks.""" self, raw_args: list[str] | str, from_validate: bool = False
if self.retry and isinstance(self.action, Action): ) -> tuple[tuple, dict]:
self.action.enable_retry() if callable(self.custom_parser):
elif self.retry_policy and isinstance(self.action, Action): if isinstance(raw_args, str):
self.action.set_retry_policy(self.retry_policy) try:
elif self.retry: raw_args = shlex.split(raw_args)
logger.warning(f"[Command:{self.key}] Retry requested, but action is not an Action instance.") except ValueError:
if self.retry_all: logger.warning(
self.action.enable_retries_recursively(self.action, self.retry_policy) "[Command:%s] Failed to split arguments: %s",
self.key,
raw_args,
)
return ((), {})
return self.custom_parser(raw_args)
if self.logging_hooks and isinstance(self.action, BaseAction): if isinstance(raw_args, str):
register_debug_hooks(self.action.hooks) try:
raw_args = shlex.split(raw_args)
except ValueError:
logger.warning(
"[Command:%s] Failed to split arguments: %s",
self.key,
raw_args,
)
return ((), {})
if not isinstance(self.arg_parser, CommandArgumentParser):
logger.warning(
"[Command:%s] No argument parser configured, using default parsing.",
self.key,
)
return ((), {})
return await self.arg_parser.parse_args_split(
raw_args, from_validate=from_validate
)
@field_validator("action", mode="before") @field_validator("action", mode="before")
@classmethod @classmethod
@ -80,11 +186,70 @@ class Command(BaseModel):
return ensure_async(action) return ensure_async(action)
raise TypeError("Action must be a callable or an instance of BaseAction") raise TypeError("Action must be a callable or an instance of BaseAction")
def __str__(self): def get_argument_definitions(self) -> list[dict[str, Any]]:
return f"Command(key='{self.key}', description='{self.description}')" if self.arguments:
return self.arguments
elif callable(self.argument_config) and isinstance(
self.arg_parser, CommandArgumentParser
):
self.argument_config(self.arg_parser)
elif self.auto_args:
if isinstance(self.action, BaseAction):
infer_target, maybe_metadata = self.action.get_infer_target()
# merge metadata with the action's metadata if not already in self.arg_metadata
if maybe_metadata:
self.arg_metadata = {**maybe_metadata, **self.arg_metadata}
return infer_args_from_func(infer_target, self.arg_metadata)
elif callable(self.action):
return infer_args_from_func(self.action, self.arg_metadata)
return []
async def __call__(self, *args, **kwargs): def model_post_init(self, _: Any) -> None:
"""Run the action with full hook lifecycle, timing, and error handling.""" """Post-initialization to set up the action and hooks."""
if self.retry and isinstance(self.action, Action):
self.action.enable_retry()
elif self.retry_policy and isinstance(self.action, Action):
self.action.set_retry_policy(self.retry_policy)
elif self.retry:
logger.warning(
"[Command:%s] Retry requested, but action is not an Action instance.",
self.key,
)
if self.retry_all and isinstance(self.action, BaseAction):
self.retry_policy.enabled = True
enable_retries_recursively(self.action, self.retry_policy)
elif self.retry_all:
logger.warning(
"[Command:%s] Retry all requested, but action is not a BaseAction.",
self.key,
)
if self.logging_hooks and isinstance(self.action, BaseAction):
register_debug_hooks(self.action.hooks)
if self.arg_parser is None:
self.arg_parser = CommandArgumentParser(
command_key=self.key,
command_description=self.description,
command_style=self.style,
help_text=self.help_text,
help_epilog=self.help_epilog,
aliases=self.aliases,
)
for arg_def in self.get_argument_definitions():
self.arg_parser.add_argument(*arg_def.pop("flags"), **arg_def)
def _inject_options_manager(self) -> None:
"""Inject the options manager into the action if applicable."""
if isinstance(self.action, BaseAction):
self.action.set_options_manager(self.options_manager)
async def __call__(self, *args, **kwargs) -> Any:
"""
Run the action with full hook lifecycle, timing, error handling,
confirmation prompts, preview, and spinner integration.
"""
self._inject_options_manager()
combined_args = args + self.args combined_args = args + self.args
combined_kwargs = {**self.kwargs, **kwargs} combined_kwargs = {**self.kwargs, **kwargs}
context = ExecutionContext( context = ExecutionContext(
@ -94,20 +259,35 @@ class Command(BaseModel):
action=self, action=self,
) )
self._context = context self._context = context
if should_prompt_user(confirm=self.confirm, options=self.options_manager):
if self.preview_before_confirm:
await self.preview()
if not await confirm_async(self.confirmation_prompt):
logger.info("[Command:%s] Cancelled by user.", self.key)
raise CancelSignal(f"[Command:{self.key}] Cancelled by confirmation.")
context.start_timer() context.start_timer()
try: try:
await self.hooks.trigger(HookType.BEFORE, context) await self.hooks.trigger(HookType.BEFORE, context)
result = await self.action(*combined_args, **combined_kwargs) if self.spinner:
with console.status(
self.spinner_message,
spinner=self.spinner_type,
spinner_style=self.spinner_style,
**self.spinner_kwargs,
):
result = await self.action(*combined_args, **combined_kwargs)
else:
result = await self.action(*combined_args, **combined_kwargs)
context.result = result context.result = result
await self.hooks.trigger(HookType.ON_SUCCESS, context) await self.hooks.trigger(HookType.ON_SUCCESS, context)
return context.result return context.result
except Exception as error: except Exception as error:
context.exception = error context.exception = error
await self.hooks.trigger(HookType.ON_ERROR, context) await self.hooks.trigger(HookType.ON_ERROR, context)
if context.result is not None:
logger.info(f"✅ Recovered: {self.key}")
return context.result
raise error raise error
finally: finally:
context.stop_timer() context.stop_timer()
@ -124,9 +304,7 @@ class Command(BaseModel):
def confirmation_prompt(self) -> FormattedText: def confirmation_prompt(self) -> FormattedText:
"""Generate a styled prompt_toolkit FormattedText confirmation message.""" """Generate a styled prompt_toolkit FormattedText confirmation message."""
if self.confirm_message and self.confirm_message != "Are you sure?": if self.confirm_message and self.confirm_message != "Are you sure?":
return FormattedText([ return FormattedText([("class:confirm", self.confirm_message)])
("class:confirm", self.confirm_message)
])
action_name = getattr(self.action, "__name__", None) action_name = getattr(self.action, "__name__", None)
if isinstance(self.action, BaseAction): if isinstance(self.action, BaseAction):
@ -141,27 +319,81 @@ class Command(BaseModel):
prompt.append(("class:confirm", f"(calls `{action_name}`) ")) prompt.append(("class:confirm", f"(calls `{action_name}`) "))
if self.args or self.kwargs: if self.args or self.kwargs:
prompt.append((OneColors.DARK_YELLOW, f"with args={self.args}, kwargs={self.kwargs} ")) prompt.append(
(OneColors.DARK_YELLOW, f"with args={self.args}, kwargs={self.kwargs} ")
)
return FormattedText(prompt) return FormattedText(prompt)
def log_summary(self): @property
def usage(self) -> str:
"""Generate a help string for the command arguments."""
if not self.arg_parser:
return "No arguments defined."
command_keys_text = self.arg_parser.get_command_keys_text(plain_text=True)
options_text = self.arg_parser.get_options_text(plain_text=True)
return f" {command_keys_text:<20} {options_text} "
@property
def help_signature(self) -> str:
"""Generate a help signature for the command."""
if self.arg_parser and not self.simple_help_signature:
signature = [self.arg_parser.get_usage()]
signature.append(f" {self.help_text or self.description}")
if self.tags:
signature.append(f" [dim]Tags: {', '.join(self.tags)}[/dim]")
return "\n".join(signature).strip()
command_keys = " | ".join(
[f"[{self.style}]{self.key}[/{self.style}]"]
+ [f"[{self.style}]{alias}[/{self.style}]" for alias in self.aliases]
)
return f"{command_keys} {self.description}"
def log_summary(self) -> None:
if self._context: if self._context:
self._context.log_summary() self._context.log_summary()
async def preview(self): def show_help(self) -> bool:
"""Display the help message for the command."""
if callable(self.custom_help):
output = self.custom_help()
if output:
console.print(output)
return True
if isinstance(self.arg_parser, CommandArgumentParser):
self.arg_parser.render_help()
return True
return False
async def preview(self) -> None:
label = f"[{OneColors.GREEN_b}]Command:[/] '{self.key}'{self.description}" label = f"[{OneColors.GREEN_b}]Command:[/] '{self.key}'{self.description}"
if hasattr(self.action, "preview") and callable(self.action.preview): if hasattr(self.action, "preview") and callable(self.action.preview):
tree = Tree(label) tree = Tree(label)
await self.action.preview(parent=tree) await self.action.preview(parent=tree)
if self.help_text:
tree.add(f"[dim]💡 {self.help_text}[/dim]")
console.print(tree) console.print(tree)
elif callable(self.action): elif callable(self.action) and not isinstance(self.action, BaseAction):
console.print(f"{label}") console.print(f"{label}")
if self.help_text:
console.print(f"[dim]💡 {self.help_text}[/dim]")
console.print( console.print(
f"[{OneColors.LIGHT_RED_b}]→ Would call:[/] {self.action.__name__} " f"[{OneColors.LIGHT_RED_b}]→ Would call:[/] {self.action.__name__}"
f"[dim](args={self.args}, kwargs={self.kwargs})[/dim]" f"[dim](args={self.args}, kwargs={self.kwargs})[/dim]"
) )
else: else:
console.print(f"{label}") console.print(f"{label}")
console.print(f"[{OneColors.DARK_RED}]⚠️ Action is not callable or lacks a preview method.[/]") if self.help_text:
console.print(f"[dim]💡 {self.help_text}[/dim]")
console.print(
f"[{OneColors.DARK_RED}]⚠️ No preview available for this action.[/]"
)
def __str__(self) -> str:
return (
f"Command(key='{self.key}', description='{self.description}' "
f"action='{self.action}')"
)

View File

@ -1,16 +1,27 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""config.py """config.py
Configuration loader for Falyx CLI commands.""" Configuration loader for Falyx CLI commands."""
from __future__ import annotations
import importlib import importlib
import sys
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Callable
import toml import toml
import yaml 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
from falyx.action.base import BaseAction
from falyx.command import Command from falyx.command import Command
from falyx.falyx import Falyx
from falyx.logger import logger
from falyx.retry import RetryPolicy 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: def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command:
@ -20,8 +31,8 @@ def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command:
return Action(name=name or getattr(obj, "__name__", "unnamed"), action=obj) return Action(name=name or getattr(obj, "__name__", "unnamed"), action=obj)
else: else:
raise TypeError( raise TypeError(
f"Cannot wrap object of type '{type(obj).__name__}' as a BaseAction or Command. " f"Cannot wrap object of type '{type(obj).__name__}'. "
"It must be a callable or an instance of BaseAction." "Expected a function or BaseAction."
) )
@ -29,14 +40,193 @@ def import_action(dotted_path: str) -> Any:
"""Dynamically imports a callable from a dotted path like 'my.module.func'.""" """Dynamically imports a callable from a dotted path like 'my.module.func'."""
module_path, _, attr = dotted_path.rpartition(".") module_path, _, attr = dotted_path.rpartition(".")
if not module_path: if not module_path:
raise ValueError(f"Invalid action path: {dotted_path}") console.print(f"[{OneColors.DARK_RED}]❌ Invalid action path:[/] {dotted_path}")
module = importlib.import_module(module_path) sys.exit(1)
return getattr(module, attr) try:
module = importlib.import_module(module_path)
except ModuleNotFoundError as error:
logger.error("Failed to import module '%s': %s", module_path, error)
console.print(
f"[{OneColors.DARK_RED}]❌ Could not import '{dotted_path}': {error}[/]\n"
f"[{OneColors.COMMENT_GREY}]Ensure the module is installed and discoverable "
"via PYTHONPATH."
)
sys.exit(1)
try:
action = getattr(module, attr)
except AttributeError as error:
logger.error(
"Module '%s' does not have attribute '%s': %s", module_path, attr, error
)
console.print(
f"[{OneColors.DARK_RED}]❌ Module '{module_path}' has no attribute "
f"'{attr}': {error}[/]"
)
sys.exit(1)
return action
def loader(file_path: str) -> list[dict[str, Any]]: class RawCommand(BaseModel):
"""Raw command model for Falyx CLI configuration."""
key: str
description: str
action: str
args: tuple[Any, ...] = Field(default_factory=tuple)
kwargs: dict[str, Any] = Field(default_factory=dict)
aliases: list[str] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
style: str = OneColors.WHITE
confirm: bool = False
confirm_message: str = "Are you sure?"
preview_before_confirm: bool = True
spinner: bool = False
spinner_message: str = "Processing..."
spinner_type: str = "dots"
spinner_style: str = OneColors.CYAN
spinner_kwargs: dict[str, Any] = Field(default_factory=dict)
before_hooks: list[Callable] = Field(default_factory=list)
success_hooks: list[Callable] = Field(default_factory=list)
error_hooks: list[Callable] = Field(default_factory=list)
after_hooks: list[Callable] = Field(default_factory=list)
teardown_hooks: list[Callable] = Field(default_factory=list)
logging_hooks: bool = False
retry: bool = False
retry_all: bool = False
retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
hidden: bool = False
help_text: str = ""
help_epilog: str = ""
@field_validator("retry_policy")
@classmethod
def validate_retry_policy(cls, value: dict | RetryPolicy) -> RetryPolicy:
if isinstance(value, RetryPolicy):
return value
if not isinstance(value, dict):
raise ValueError("retry_policy must be a dictionary.")
return RetryPolicy(**value)
def convert_commands(raw_commands: list[dict[str, Any]]) -> list[Command]:
commands = []
for entry in raw_commands:
raw_command = RawCommand(**entry)
commands.append(
Command.model_validate(
{
**raw_command.model_dump(exclude={"action"}),
"action": wrap_if_needed(
import_action(raw_command.action), name=raw_command.description
),
}
)
)
return commands
def convert_submenus(
raw_submenus: list[dict[str, Any]], *, parent_path: Path | None = None, depth: int = 0
) -> list[dict[str, Any]]:
submenus: list[dict[str, Any]] = []
for raw_submenu in raw_submenus:
if raw_submenu.get("config"):
config_path = Path(raw_submenu["config"])
if parent_path:
config_path = (parent_path.parent / config_path).resolve()
submenu = loader(config_path, _depth=depth + 1)
else:
submenu_module_path = raw_submenu.get("submenu")
if not isinstance(submenu_module_path, str):
console.print(
f"[{OneColors.DARK_RED}]❌ Invalid submenu path:[/] {submenu_module_path}"
)
sys.exit(1)
submenu = import_action(submenu_module_path)
if not isinstance(submenu, Falyx):
console.print(f"[{OneColors.DARK_RED}]❌ Invalid submenu:[/] {submenu}")
sys.exit(1)
key = raw_submenu.get("key")
if not isinstance(key, str):
console.print(f"[{OneColors.DARK_RED}]❌ Invalid submenu key:[/] {key}")
sys.exit(1)
description = raw_submenu.get("description")
if not isinstance(description, str):
console.print(
f"[{OneColors.DARK_RED}]❌ Invalid submenu description:[/] {description}"
)
sys.exit(1)
submenus.append(
Submenu(
key=key,
description=description,
submenu=submenu,
style=raw_submenu.get("style", OneColors.CYAN),
).model_dump()
)
return submenus
class Submenu(BaseModel):
"""Submenu model for Falyx CLI configuration."""
key: str
description: str
submenu: Any
style: str = OneColors.CYAN
class FalyxConfig(BaseModel):
"""Falyx CLI configuration model."""
title: str = "Falyx CLI"
prompt: str | list[tuple[str, str]] | list[list[str]] = [
(OneColors.BLUE_b, "FALYX > ")
]
columns: int = 4
welcome_message: str = ""
exit_message: str = ""
commands: list[Command] | list[dict] = []
submenus: list[dict[str, Any]] = []
@model_validator(mode="after")
def validate_prompt_format(self) -> FalyxConfig:
if isinstance(self.prompt, list):
for pair in self.prompt:
if not isinstance(pair, (list, tuple)) or len(pair) != 2:
raise ValueError(
"Prompt list must contain 2-element (style, text) pairs"
)
return self
def to_falyx(self) -> Falyx:
flx = Falyx(
title=self.title,
prompt=self.prompt, # type: ignore[arg-type]
columns=self.columns,
welcome_message=self.welcome_message,
exit_message=self.exit_message,
)
flx.add_commands(self.commands)
for submenu in self.submenus:
flx.add_submenu(**submenu)
return flx
def loader(file_path: Path | str, _depth: int = 0) -> Falyx:
""" """
Load command definitions from a YAML or TOML file. Load Falyx CLI configuration from a YAML or TOML file.
The file should contain a dictionary with a list of commands.
Each command should be defined as a dictionary with at least: Each command should be defined as a dictionary with at least:
- key: a unique single-character key - key: a unique single-character key
@ -47,12 +237,19 @@ def loader(file_path: str) -> list[dict[str, Any]]:
file_path (str): Path to the config file (YAML or TOML). file_path (str): Path to the config file (YAML or TOML).
Returns: Returns:
list[dict[str, Any]]: A list of command configuration dictionaries. Falyx: An instance of the Falyx CLI with loaded commands.
Raises: Raises:
ValueError: If the file format is unsupported or file cannot be parsed. ValueError: If the file format is unsupported or file cannot be parsed.
""" """
path = Path(file_path) if _depth > 5:
raise ValueError("Maximum submenu depth exceeded (5 levels deep)")
if isinstance(file_path, (str, Path)):
path = Path(file_path)
else:
raise TypeError("file_path must be a string or Path object.")
if not path.is_file(): if not path.is_file():
raise FileNotFoundError(f"No such config file: {file_path}") raise FileNotFoundError(f"No such config file: {file_path}")
@ -65,39 +262,25 @@ def loader(file_path: str) -> list[dict[str, Any]]:
else: else:
raise ValueError(f"Unsupported config format: {suffix}") raise ValueError(f"Unsupported config format: {suffix}")
if not isinstance(raw_config, list): if not isinstance(raw_config, dict):
raise ValueError("Configuration file must contain a list of command definitions.") raise ValueError(
"Configuration file must contain a dictionary with a list of commands.\n"
"Example:\n"
required = ["key", "description", "action"] "title: 'My CLI'\n"
commands = [] "commands:\n"
for entry in raw_config: " - key: 'a'\n"
for field in required: " description: 'Example command'\n"
if field not in entry: " action: 'my_module.my_function'"
raise ValueError(f"Missing '{field}' in command entry: {entry}") )
command_dict = {
"key": entry["key"],
"description": entry["description"],
"aliases": entry.get("aliases", []),
"action": wrap_if_needed(import_action(entry["action"]),
name=entry["description"]),
"args": tuple(entry.get("args", ())),
"kwargs": entry.get("kwargs", {}),
"help_text": entry.get("help_text", ""),
"color": entry.get("color", "white"),
"confirm": entry.get("confirm", False),
"confirm_message": entry.get("confirm_message", "Are you sure?"),
"preview_before_confirm": entry.get("preview_before_confirm", True),
"spinner": entry.get("spinner", False),
"spinner_message": entry.get("spinner_message", "Processing..."),
"spinner_type": entry.get("spinner_type", "dots"),
"spinner_style": entry.get("spinner_style", "cyan"),
"spinner_kwargs": entry.get("spinner_kwargs", {}),
"tags": entry.get("tags", []),
"retry_policy": RetryPolicy(**entry.get("retry_policy", {})),
}
commands.append(command_dict)
return commands
commands = convert_commands(raw_config["commands"])
submenus = convert_submenus(raw_config.get("submenus", []))
return FalyxConfig(
title=raw_config.get("title", f"[{OneColors.BLUE_b}]Falyx CLI"),
prompt=raw_config.get("prompt", [(OneColors.BLUE_b, "FALYX > ")]),
columns=raw_config.get("columns", 4),
welcome_message=raw_config.get("welcome_message", ""),
exit_message=raw_config.get("exit_message", ""),
commands=commands,
submenus=submenus,
).to_falyx()

View File

@ -1,4 +1,22 @@
"""context.py""" # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Execution context management for Falyx CLI actions.
This module defines `ExecutionContext` and `SharedContext`, which are responsible for
capturing per-action and cross-action metadata during CLI workflow execution. These
context objects provide structured introspection, result tracking, error recording,
and time-based performance metrics.
- `ExecutionContext`: Captures runtime information for a single action execution,
including arguments, results, exceptions, timing, and logging.
- `SharedContext`: Maintains shared state and result propagation across
`ChainedAction` or `ActionGroup` executions.
These contexts enable rich introspection, traceability, and workflow coordination,
supporting hook lifecycles, retries, and structured output generation.
"""
from __future__ import annotations
import time import time
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
@ -8,9 +26,51 @@ from rich.console import Console
class ExecutionContext(BaseModel): class ExecutionContext(BaseModel):
"""
Represents the runtime metadata and state for a single action execution.
The `ExecutionContext` tracks arguments, results, exceptions, timing, and
additional metadata for each invocation of a Falyx `BaseAction`. It provides
integration with the Falyx hook system and execution registry, enabling lifecycle
management, diagnostics, and structured logging.
Attributes:
name (str): The name of the action being executed.
args (tuple): Positional arguments passed to the action.
kwargs (dict): Keyword arguments passed to the action.
action (BaseAction | Callable): The action instance being executed.
result (Any | None): The result of the action, if successful.
exception (Exception | None): The exception raised, if execution failed.
start_time (float | None): High-resolution performance start time.
end_time (float | None): High-resolution performance end time.
start_wall (datetime | None): Wall-clock timestamp when execution began.
end_wall (datetime | None): Wall-clock timestamp when execution ended.
extra (dict): Metadata for custom introspection or special use by Actions.
console (Console): Rich console instance for logging or UI output.
shared_context (SharedContext | None): Optional shared context when running in
a chain or group.
Properties:
duration (float | None): The execution duration in seconds.
success (bool): Whether the action completed without raising an exception.
status (str): Returns "OK" if successful, otherwise "ERROR".
Methods:
start_timer(): Starts the timing and timestamp tracking.
stop_timer(): Stops timing and stores end timestamps.
log_summary(logger=None): Logs a rich or plain summary of execution.
to_log_line(): Returns a single-line log entry for metrics or tracing.
as_dict(): Serializes core result and diagnostic metadata.
get_shared_context(): Returns the shared context or creates a default one.
This class is used internally by all Falyx actions and hook events. It ensures
consistent tracking and reporting across asynchronous workflows, including CLI-driven
and automated batch executions.
"""
name: str name: str
args: tuple = () args: tuple = ()
kwargs: dict = {} kwargs: dict = Field(default_factory=dict)
action: Any action: Any
result: Any | None = None result: Any | None = None
exception: Exception | None = None exception: Exception | None = None
@ -20,8 +80,12 @@ class ExecutionContext(BaseModel):
start_wall: datetime | None = None start_wall: datetime | None = None
end_wall: datetime | None = None end_wall: datetime | None = None
index: int | None = None
extra: dict[str, Any] = Field(default_factory=dict) extra: dict[str, Any] = Field(default_factory=dict)
console: Console = Field(default_factory=lambda: Console(color_system="auto")) console: Console = Field(default_factory=lambda: Console(color_system="truecolor"))
shared_context: SharedContext | None = None
model_config = ConfigDict(arbitrary_types_allowed=True) model_config = ConfigDict(arbitrary_types_allowed=True)
@ -33,6 +97,13 @@ class ExecutionContext(BaseModel):
self.end_time = time.perf_counter() self.end_time = time.perf_counter()
self.end_wall = datetime.now() self.end_wall = datetime.now()
def get_shared_context(self) -> SharedContext:
if not self.shared_context:
raise ValueError(
"SharedContext is not set. This context is not part of a chain or group."
)
return self.shared_context
@property @property
def duration(self) -> float | None: def duration(self) -> float | None:
if self.start_time is None: if self.start_time is None:
@ -49,6 +120,17 @@ class ExecutionContext(BaseModel):
def status(self) -> str: def status(self) -> str:
return "OK" if self.success else "ERROR" return "OK" if self.success else "ERROR"
@property
def signature(self) -> str:
"""
Returns a string representation of the action signature, including
its name and arguments.
"""
args = ", ".join(map(repr, self.args))
kwargs = ", ".join(f"{key}={value!r}" for key, value in self.kwargs.items())
signature = ", ".join(filter(None, [args, kwargs]))
return f"{self.name} ({signature})"
def as_dict(self) -> dict: def as_dict(self) -> dict:
return { return {
"name": self.name, "name": self.name,
@ -58,28 +140,32 @@ class ExecutionContext(BaseModel):
"extra": self.extra, "extra": self.extra,
} }
def log_summary(self, logger=None): def log_summary(self, logger=None) -> None:
summary = self.as_dict() summary = self.as_dict()
message = [f"[SUMMARY] {summary['name']} | "] message = [f"[SUMMARY] {summary['name']} | "]
if self.start_wall: if self.start_wall:
message.append(f"Start: {self.start_wall.strftime('%H:%M:%S')} | ") message.append(f"Start: {self.start_wall.strftime('%H:%M:%S')} | ")
if self.end_time: if self.end_wall:
message.append(f"End: {self.end_wall.strftime('%H:%M:%S')} | ") message.append(f"End: {self.end_wall.strftime('%H:%M:%S')} | ")
message.append(f"Duration: {summary['duration']:.3f}s | ") message.append(f"Duration: {summary['duration']:.3f}s | ")
if summary["exception"]: if summary["exception"]:
message.append(f"Exception: {summary['exception']}") message.append(f"Exception: {summary['exception']}")
else: else:
message.append(f"Result: {summary['result']}") message.append(f"Result: {summary['result']}")
(logger or self.console.print)("".join(message)) (logger or self.console.print)("".join(message))
def to_log_line(self) -> str: def to_log_line(self) -> str:
"""Structured flat-line format for logging and metrics.""" """Structured flat-line format for logging and metrics."""
duration_str = f"{self.duration:.3f}s" if self.duration is not None else "n/a" duration_str = f"{self.duration:.3f}s" if self.duration is not None else "n/a"
exception_str = f"{type(self.exception).__name__}: {self.exception}" if self.exception else "None" exception_str = (
f"{type(self.exception).__name__}: {self.exception}"
if self.exception
else "None"
)
return ( return (
f"[{self.name}] status={self.status} duration={duration_str} " f"[{self.name}] status={self.status} duration={duration_str} "
f"result={repr(self.result)} exception={exception_str}" f"result={repr(self.result)} exception={exception_str}"
@ -87,7 +173,11 @@ class ExecutionContext(BaseModel):
def __str__(self) -> str: def __str__(self) -> str:
duration_str = f"{self.duration:.3f}s" if self.duration is not None else "n/a" duration_str = f"{self.duration:.3f}s" if self.duration is not None else "n/a"
result_str = f"Result: {repr(self.result)}" if self.success else f"Exception: {self.exception}" result_str = (
f"Result: {repr(self.result)}"
if self.success
else f"Exception: {self.exception}"
)
return ( return (
f"<ExecutionContext '{self.name}' | {self.status} | " f"<ExecutionContext '{self.name}' | {self.status} | "
f"Duration: {duration_str} | {result_str}>" f"Duration: {duration_str} | {result_str}>"
@ -103,19 +193,58 @@ class ExecutionContext(BaseModel):
) )
class ResultsContext(BaseModel): class SharedContext(BaseModel):
"""
SharedContext maintains transient shared state during the execution
of a ChainedAction or ActionGroup.
This context object is passed to all actions within a chain or group,
enabling result propagation, shared data exchange, and coordinated
tracking of execution order and failures.
Attributes:
name (str): Identifier for the context (usually the parent action name).
results (list[Any]): Captures results from each action, in order of execution.
errors (list[tuple[int, Exception]]): Indexed list of errors from failed actions.
current_index (int): Index of the currently executing action (used in chains).
is_parallel (bool): Whether the context is used in parallel mode (ActionGroup).
shared_result (Any | None): Optional shared value available to all actions in
parallel mode.
share (dict[str, Any]): Custom shared key-value store for user-defined
communication
between actions (e.g., flags, intermediate data, settings).
Note:
SharedContext is only used within grouped or chained workflows. It should not be
used for standalone `Action` executions, where state should be scoped to the
individual ExecutionContext instead.
Example usage:
- In a ChainedAction: last_result is pulled from `results[-1]`.
- In an ActionGroup: all actions can read/write `shared_result` or use `share`.
This class supports fault-tolerant and modular composition of CLI workflows
by enabling flexible intra-action communication without global state.
"""
name: str name: str
action: Any
results: list[Any] = Field(default_factory=list) results: list[Any] = Field(default_factory=list)
errors: list[tuple[int, Exception]] = Field(default_factory=list) errors: list[tuple[int, Exception]] = Field(default_factory=list)
current_index: int = -1 current_index: int = -1
is_parallel: bool = False is_parallel: bool = False
shared_result: Any | None = None shared_result: Any | None = None
share: dict[str, Any] = Field(default_factory=dict)
model_config = ConfigDict(arbitrary_types_allowed=True) model_config = ConfigDict(arbitrary_types_allowed=True)
def add_result(self, result: Any) -> None: def add_result(self, result: Any) -> None:
self.results.append(result) self.results.append(result)
def add_error(self, index: int, error: Exception) -> None:
self.errors.append((index, error))
def set_shared_result(self, result: Any) -> None: def set_shared_result(self, result: Any) -> None:
self.shared_result = result self.shared_result = result
if self.is_parallel: if self.is_parallel:
@ -126,14 +255,21 @@ class ResultsContext(BaseModel):
return self.shared_result return self.shared_result
return self.results[-1] if self.results else None return self.results[-1] if self.results else None
def get(self, key: str, default: Any = None) -> Any:
return self.share.get(key, default)
def set(self, key: str, value: Any) -> None:
self.share[key] = value
def __str__(self) -> str: def __str__(self) -> str:
parallel_label = "Parallel" if self.is_parallel else "Sequential" parallel_label = "Parallel" if self.is_parallel else "Sequential"
return ( return (
f"<{parallel_label}ResultsContext '{self.name}' | " f"<{parallel_label}SharedContext '{self.name}' | "
f"Results: {self.results} | " f"Results: {self.results} | "
f"Errors: {self.errors}>" f"Errors: {self.errors}>"
) )
if __name__ == "__main__": if __name__ == "__main__":
import asyncio import asyncio

View File

@ -1,14 +1,16 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""debug.py"""
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.hook_manager import HookManager, HookType from falyx.hook_manager import HookManager, HookType
from falyx.utils import logger from falyx.logger import logger
def log_before(context: ExecutionContext): def log_before(context: ExecutionContext):
"""Log the start of an action.""" """Log the start of an action."""
args = ", ".join(map(repr, context.args)) args = ", ".join(map(repr, context.args))
kwargs = ", ".join(f"{k}={v!r}" for k, v in context.kwargs.items()) kwargs = ", ".join(f"{key}={value!r}" for key, value in context.kwargs.items())
signature = ", ".join(filter(None, [args, kwargs])) signature = ", ".join(filter(None, [args, kwargs]))
logger.info("[%s] 🚀 Starting → %s(%s)", context.name, context.action, signature) logger.info("[%s] Starting -> %s(%s)", context.name, context.action, signature)
def log_success(context: ExecutionContext): def log_success(context: ExecutionContext):
@ -16,18 +18,18 @@ def log_success(context: ExecutionContext):
result_str = repr(context.result) result_str = repr(context.result)
if len(result_str) > 100: if len(result_str) > 100:
result_str = f"{result_str[:100]} ..." result_str = f"{result_str[:100]} ..."
logger.debug("[%s] ✅ Success → Result: %s", context.name, result_str) logger.debug("[%s] Success -> Result: %s", context.name, result_str)
def log_after(context: ExecutionContext): def log_after(context: ExecutionContext):
"""Log the completion of an action, regardless of success or failure.""" """Log the completion of an action, regardless of success or failure."""
logger.debug("[%s] ⏱️ Finished in %.3fs", context.name, context.duration) logger.debug("[%s] Finished in %.3fs", context.name, context.duration)
def log_error(context: ExecutionContext): def log_error(context: ExecutionContext):
"""Log an error that occurred during the action.""" """Log an error that occurred during the action."""
logger.error( logger.error(
"[%s] Error (%s): %s", "[%s] Error (%s): %s",
context.name, context.name,
type(context.exception).__name__, type(context.exception).__name__,
context.exception, context.exception,

View File

@ -1,3 +1,7 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""exceptions.py"""
class FalyxError(Exception): class FalyxError(Exception):
"""Custom exception for the Menu class.""" """Custom exception for the Menu class."""
@ -20,3 +24,11 @@ class NotAFalyxError(FalyxError):
class CircuitBreakerOpen(FalyxError): class CircuitBreakerOpen(FalyxError):
"""Exception raised when the circuit breaker is open.""" """Exception raised when the circuit breaker is open."""
class EmptyChainError(FalyxError):
"""Exception raised when the chain is empty."""
class CommandArgumentError(FalyxError):
"""Exception raised when there is an error in the command argument parser."""

View File

@ -1,34 +1,100 @@
"""execution_registry.py""" # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
execution_registry.py
This module provides the `ExecutionRegistry`, a global class for tracking and
introspecting the execution history of Falyx actions.
The registry captures `ExecutionContext` instances from all executed actions, making it
easy to debug, audit, and visualize workflow behavior over time. It supports retrieval,
filtering, clearing, and formatted summary display.
Core Features:
- Stores all action execution contexts globally (with access by name).
- Provides live execution summaries in a rich table format.
- Enables creation of a built-in Falyx Action to print history on demand.
- Integrates with Falyx's introspectable and hook-driven execution model.
Intended for:
- Debugging and diagnostics
- Post-run inspection of CLI workflows
- Interactive tools built with Falyx
Example:
from falyx.execution_registry import ExecutionRegistry as er
er.record(context)
er.summary()
"""
from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from datetime import datetime from datetime import datetime
from typing import Dict, List from threading import Lock
from typing import Literal
from rich import box from rich import box
from rich.console import Console from rich.console import Console
from rich.table import Table from rich.table import Table
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.utils import logger from falyx.logger import logger
from falyx.themes import OneColors
class ExecutionRegistry: class ExecutionRegistry:
_store_by_name: Dict[str, List[ExecutionContext]] = defaultdict(list) """
_store_all: List[ExecutionContext] = [] Global registry for recording and inspecting Falyx action executions.
This class captures every `ExecutionContext` generated by a Falyx `Action`,
`ChainedAction`, or `ActionGroup`, maintaining both full history and
name-indexed access for filtered analysis.
Methods:
- record(context): Stores an ExecutionContext, logging a summary line.
- get_all(): Returns the list of all recorded executions.
- get_by_name(name): Returns all executions with the given action name.
- get_latest(): Returns the most recent execution.
- clear(): Wipes the registry for a fresh run.
- summary(): Renders a formatted Rich table of all execution results.
Use Cases:
- Debugging chained or factory-generated workflows
- Viewing results and exceptions from multiple runs
- Embedding a diagnostic command into your CLI for user support
Note:
This registry is in-memory and not persistent. It's reset each time the process
restarts or `clear()` is called.
Example:
ExecutionRegistry.record(context)
ExecutionRegistry.summary()
"""
_store_by_name: dict[str, list[ExecutionContext]] = defaultdict(list)
_store_by_index: dict[int, ExecutionContext] = {}
_store_all: list[ExecutionContext] = []
_console = Console(color_system="truecolor") _console = Console(color_system="truecolor")
_index = 0
_lock = Lock()
@classmethod @classmethod
def record(cls, context: ExecutionContext): def record(cls, context: ExecutionContext):
"""Record an execution context.""" """Record an execution context."""
logger.debug(context.to_log_line()) logger.debug(context.to_log_line())
with cls._lock:
context.index = cls._index
cls._store_by_index[cls._index] = context
cls._index += 1
cls._store_by_name[context.name].append(context) cls._store_by_name[context.name].append(context)
cls._store_all.append(context) cls._store_all.append(context)
@classmethod @classmethod
def get_all(cls) -> List[ExecutionContext]: def get_all(cls) -> list[ExecutionContext]:
return cls._store_all return cls._store_all
@classmethod @classmethod
def get_by_name(cls, name: str) -> List[ExecutionContext]: def get_by_name(cls, name: str) -> list[ExecutionContext]:
return cls._store_by_name.get(name, []) return cls._store_by_name.get(name, [])
@classmethod @classmethod
@ -39,11 +105,79 @@ class ExecutionRegistry:
def clear(cls): def clear(cls):
cls._store_by_name.clear() cls._store_by_name.clear()
cls._store_all.clear() cls._store_all.clear()
cls._store_by_index.clear()
@classmethod @classmethod
def summary(cls): def summary(
table = Table(title="[📊] Execution History", expand=True, box=box.SIMPLE) cls,
name: str = "",
index: int | None = None,
result: int | None = None,
clear: bool = False,
last_result: bool = False,
status: Literal["all", "success", "error"] = "all",
):
if clear:
cls.clear()
cls._console.print(f"[{OneColors.GREEN}]✅ Execution history cleared.")
return
if last_result:
for ctx in reversed(cls._store_all):
if ctx.name.upper() not in [
"HISTORY",
"HELP",
"EXIT",
"VIEW EXECUTION HISTORY",
"BACK",
]:
cls._console.print(ctx.result)
return
cls._console.print(
f"[{OneColors.DARK_RED}]❌ No valid executions found to display last result."
)
return
if result is not None and result >= 0:
try:
result_context = cls._store_by_index[result]
except KeyError:
cls._console.print(
f"[{OneColors.DARK_RED}]❌ No execution found for index {index}."
)
return
cls._console.print(f"{result_context.signature}:")
if result_context.exception:
cls._console.print(result_context.exception)
else:
cls._console.print(result_context.result)
return
if name:
contexts = cls.get_by_name(name)
if not contexts:
cls._console.print(
f"[{OneColors.DARK_RED}]❌ No executions found for action '{name}'."
)
return
title = f"📊 Execution History for '{contexts[0].name}'"
elif index is not None and index >= 0:
try:
contexts = [cls._store_by_index[index]]
print(contexts)
except KeyError:
cls._console.print(
f"[{OneColors.DARK_RED}]❌ No execution found for index {index}."
)
return
title = f"📊 Execution History for Index {index}"
else:
contexts = cls.get_all()
title = "📊 Execution History"
table = Table(title=title, expand=True, box=box.SIMPLE)
table.add_column("Index", justify="right", style="dim")
table.add_column("Name", style="bold cyan") table.add_column("Name", style="bold cyan")
table.add_column("Start", justify="right", style="dim") table.add_column("Start", justify="right", style="dim")
table.add_column("End", justify="right", style="dim") table.add_column("End", justify="right", style="dim")
@ -51,26 +185,32 @@ class ExecutionRegistry:
table.add_column("Status", style="bold") table.add_column("Status", style="bold")
table.add_column("Result / Exception", overflow="fold") table.add_column("Result / Exception", overflow="fold")
for ctx in cls.get_all(): for ctx in contexts:
start = datetime.fromtimestamp(ctx.start_time).strftime("%H:%M:%S") if ctx.start_time else "n/a" start = (
end = datetime.fromtimestamp(ctx.end_time).strftime("%H:%M:%S") if ctx.end_time else "n/a" datetime.fromtimestamp(ctx.start_time).strftime("%H:%M:%S")
if ctx.start_time
else "n/a"
)
end = (
datetime.fromtimestamp(ctx.end_time).strftime("%H:%M:%S")
if ctx.end_time
else "n/a"
)
duration = f"{ctx.duration:.3f}s" if ctx.duration else "n/a" duration = f"{ctx.duration:.3f}s" if ctx.duration else "n/a"
if ctx.exception: if ctx.exception and status.lower() in ["all", "error"]:
status = "[bold red]❌ Error" final_status = f"[{OneColors.DARK_RED}]❌ Error"
result = repr(ctx.exception) final_result = repr(ctx.exception)
elif status.lower() in ["all", "success"]:
final_status = f"[{OneColors.GREEN}]✅ Success"
final_result = repr(ctx.result)
if len(final_result) > 1000:
final_result = f"{final_result[:1000]}..."
else: else:
status = "[green]✅ Success" continue
result = repr(ctx.result)
table.add_row(ctx.name, start, end, duration, status, result) table.add_row(
str(ctx.index), ctx.name, start, end, duration, final_status, final_result
)
cls._console.print(table) cls._console.print(table)
@classmethod
def get_history_action(cls) -> "Action":
"""Return an Action that prints the execution summary."""
from falyx.action import Action
async def show_history():
cls.summary()
return Action(name="View Execution History", action=show_history)

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +1,22 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""hook_manager.py""" """hook_manager.py"""
from __future__ import annotations from __future__ import annotations
import inspect import inspect
from enum import Enum from enum import Enum
from typing import Awaitable, Callable, Dict, List, Optional, Union from typing import Awaitable, Callable, Union
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.utils import logger from falyx.logger import logger
Hook = Union[ Hook = Union[
Callable[[ExecutionContext], None], Callable[[ExecutionContext], None], Callable[[ExecutionContext], Awaitable[None]]
Callable[[ExecutionContext], Awaitable[None]]
] ]
class HookType(Enum): class HookType(Enum):
"""Enum for hook types to categorize the hooks.""" """Enum for hook types to categorize the hooks."""
BEFORE = "before" BEFORE = "before"
ON_SUCCESS = "on_success" ON_SUCCESS = "on_success"
ON_ERROR = "on_error" ON_ERROR = "on_error"
@ -23,7 +24,7 @@ class HookType(Enum):
ON_TEARDOWN = "on_teardown" ON_TEARDOWN = "on_teardown"
@classmethod @classmethod
def choices(cls) -> List[HookType]: def choices(cls) -> list[HookType]:
"""Return a list of all hook type choices.""" """Return a list of all hook type choices."""
return list(cls) return list(cls)
@ -33,17 +34,20 @@ class HookType(Enum):
class HookManager: class HookManager:
"""HookManager"""
def __init__(self) -> None: def __init__(self) -> None:
self._hooks: Dict[HookType, List[Hook]] = { self._hooks: dict[HookType, list[Hook]] = {
hook_type: [] for hook_type in HookType hook_type: [] for hook_type in HookType
} }
def register(self, hook_type: HookType, hook: Hook): def register(self, hook_type: HookType | str, hook: Hook):
if hook_type not in HookType: """Raises ValueError if the hook type is not supported."""
raise ValueError(f"Unsupported hook type: {hook_type}") if not isinstance(hook_type, HookType):
hook_type = HookType(hook_type)
self._hooks[hook_type].append(hook) self._hooks[hook_type].append(hook)
def clear(self, hook_type: Optional[HookType] = None): def clear(self, hook_type: HookType | None = None):
if hook_type: if hook_type:
self._hooks[hook_type] = [] self._hooks[hook_type] = []
else: else:
@ -60,9 +64,28 @@ class HookManager:
else: else:
hook(context) hook(context)
except Exception as hook_error: except Exception as hook_error:
logger.warning(f"⚠️ Hook '{hook.__name__}' raised an exception during '{hook_type}'" logger.warning(
f" for '{context.name}': {hook_error}") "[Hook:%s] raised an exception during '%s' for '%s': %s",
hook.__name__,
hook_type,
context.name,
hook_error,
)
if hook_type == HookType.ON_ERROR: if hook_type == HookType.ON_ERROR:
assert isinstance(context.exception, BaseException) assert isinstance(
context.exception, Exception
), "Context exception should be set for ON_ERROR hook"
raise context.exception from hook_error raise context.exception from hook_error
def __str__(self) -> str:
"""Return a formatted string of registered hooks grouped by hook type."""
def format_hook_list(hooks: list[Hook]) -> str:
return ", ".join(h.__name__ for h in hooks) if hooks else ""
lines = ["<HookManager>"]
for hook_type in HookType:
hook_list = self._hooks.get(hook_type, [])
lines.append(f" {hook_type.value}: {format_hook_list(hook_list)}")
return "\n".join(lines)

View File

@ -1,32 +1,50 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""hooks.py""" """hooks.py"""
import time import time
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.themes.colors import OneColors from falyx.logger import logger
from falyx.utils import logger from falyx.themes import OneColors
class ResultReporter: class ResultReporter:
def __init__(self, formatter: callable = None): """Reports the success of an action."""
def __init__(self, formatter: Callable[[Any], str] | None = None):
""" """
Optional result formatter. If not provided, uses repr(result). Optional result formatter. If not provided, uses repr(result).
""" """
self.formatter = formatter or (lambda r: repr(r)) self.formatter = formatter or (self.default_formatter)
def default_formatter(self, result: Any):
"""
Default formatter for results. Converts the result to a string.
"""
return repr(result)
@property @property
def __name__(self): def __name__(self):
return "ResultReporter" return "ResultReporter"
async def report(self, context: ExecutionContext): async def report(self, context: ExecutionContext):
if not callable(self.formatter):
raise TypeError("formatter must be callable")
if context.result is not None: if context.result is not None:
result_text = self.formatter(context.result) result_text = self.formatter(context.result)
duration = f"{context.duration:.3f}s" if context.duration is not None else "n/a" duration = (
context.console.print(f"[{OneColors.GREEN}]✅ '{context.name}' " f"{context.duration:.3f}s" if context.duration is not None else "n/a"
f"completed:[/] {result_text} in {duration}.") )
context.console.print(
f"[{OneColors.GREEN}]✅ '{context.name}' "
f"completed:[/] {result_text} in {duration}."
)
class CircuitBreaker: class CircuitBreaker:
"""Circuit Breaker pattern to prevent repeated failures."""
def __init__(self, max_failures=3, reset_timeout=10): def __init__(self, max_failures=3, reset_timeout=10):
self.max_failures = max_failures self.max_failures = max_failures
self.reset_timeout = reset_timeout self.reset_timeout = reset_timeout
@ -37,21 +55,30 @@ class CircuitBreaker:
name = context.name name = context.name
if self.open_until: if self.open_until:
if time.time() < self.open_until: if time.time() < self.open_until:
raise CircuitBreakerOpen(f"🔴 Circuit open for '{name}' until {time.ctime(self.open_until)}.") raise CircuitBreakerOpen(
f"Circuit open for '{name}' until {time.ctime(self.open_until)}."
)
else: else:
logger.info(f"🟢 Circuit closed again for '{name}'.") logger.info("Circuit closed again for '%s'.")
self.failures = 0 self.failures = 0
self.open_until = None self.open_until = None
def error_hook(self, context: ExecutionContext): def error_hook(self, context: ExecutionContext):
name = context.name name = context.name
self.failures += 1 self.failures += 1
logger.warning(f"⚠️ CircuitBreaker: '{name}' failure {self.failures}/{self.max_failures}.") logger.warning(
"CircuitBreaker: '%s' failure %s/%s.",
name,
self.failures,
self.max_failures,
)
if self.failures >= self.max_failures: if self.failures >= self.max_failures:
self.open_until = time.time() + self.reset_timeout self.open_until = time.time() + self.reset_timeout
logger.error(f"🔴 Circuit opened for '{name}' until {time.ctime(self.open_until)}.") logger.error(
"Circuit opened for '%s' until %s.", name, time.ctime(self.open_until)
)
def after_hook(self, context: ExecutionContext): def after_hook(self, _: ExecutionContext):
self.failures = 0 self.failures = 0
def is_open(self): def is_open(self):
@ -60,4 +87,4 @@ class CircuitBreaker:
def reset(self): def reset(self):
self.failures = 0 self.failures = 0
self.open_until = None self.open_until = None
logger.info("🔄 Circuit reset.") logger.info("Circuit reset.")

View File

@ -1,29 +0,0 @@
"""importer.py"""
import importlib
from types import ModuleType
from typing import Any, Callable
def resolve_action(path: str) -> Callable[..., Any]:
"""
Resolve a dotted path to a Python callable.
Example: 'mypackage.mymodule.myfunction'
Raises:
ImportError if the module or function does not exist.
ValueError if the resolved attribute is not callable.
"""
if ":" in path:
module_path, function_name = path.split(":")
else:
*module_parts, function_name = path.split(".")
module_path = ".".join(module_parts)
module: ModuleType = importlib.import_module(module_path)
function: Any = getattr(module, function_name)
if not callable(function):
raise ValueError(f"Resolved attribute '{function_name}' is not callable.")
return function

135
falyx/init.py Normal file
View File

@ -0,0 +1,135 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""init.py"""
from pathlib import Path
from rich.console import Console
TEMPLATE_TASKS = """\
# This file is used by falyx.yaml to define CLI actions.
# You can run: falyx run [key] or falyx list to see available commands.
import asyncio
import json
from falyx.action import Action, ChainedAction, ShellAction, SelectionAction
post_ids = ["1", "2", "3", "4", "5"]
pick_post = SelectionAction(
name="Pick Post ID",
selections=post_ids,
title="Choose a Post ID",
prompt_message="Select a post > ",
)
fetch_post = ShellAction(
name="Fetch Post via curl",
command_template="curl https://jsonplaceholder.typicode.com/posts/{}",
)
async def get_post_title(last_result):
return json.loads(last_result).get("title", "No title found")
post_flow = ChainedAction(
name="Fetch and Parse Post",
actions=[pick_post, fetch_post, get_post_title],
auto_inject=True,
)
async def hello():
print("👋 Hello from Falyx!")
return "Hello Complete!"
async def some_work():
await asyncio.sleep(2)
print("Work Finished!")
return "Work Complete!"
work_action = Action(
name="Work Action",
action=some_work,
)
"""
TEMPLATE_CONFIG = """\
# falyx.yaml — Config-driven CLI definition
# Define your commands here and point to Python callables in tasks.py
title: Sample CLI Project
prompt:
- ["#61AFEF bold", "FALYX > "]
columns: 3
welcome_message: "🚀 Welcome to your new CLI project!"
exit_message: "👋 See you next time!"
commands:
- key: S
description: Say Hello
action: tasks.hello
aliases: [hi, hello]
tags: [example]
- key: P
description: Get Post Title
action: tasks.post_flow
aliases: [submit]
preview_before_confirm: true
confirm: true
tags: [demo, network]
- key: G
description: Do Some Work
action: tasks.work_action
aliases: [work]
spinner: true
spinner_message: "Working..."
"""
GLOBAL_TEMPLATE_TASKS = """\
async def cleanup():
print("🧹 Cleaning temp files...")
"""
GLOBAL_CONFIG = """\
title: Global Falyx Config
commands:
- key: C
description: Cleanup temp files
action: tasks.cleanup
aliases: [clean, cleanup]
"""
console = Console(color_system="truecolor")
def init_project(name: str) -> None:
target = Path(name).resolve()
target.mkdir(parents=True, exist_ok=True)
tasks_path = target / "tasks.py"
config_path = target / "falyx.yaml"
if tasks_path.exists() or config_path.exists():
console.print(f"⚠️ Project already initialized at {target}")
return None
tasks_path.write_text(TEMPLATE_TASKS)
config_path.write_text(TEMPLATE_CONFIG)
console.print(f"✅ Initialized Falyx project in {target}")
def init_global() -> None:
config_dir = Path.home() / ".config" / "falyx"
config_dir.mkdir(parents=True, exist_ok=True)
tasks_path = config_dir / "tasks.py"
config_path = config_dir / "falyx.yaml"
if tasks_path.exists() or config_path.exists():
console.print("⚠️ Global Falyx config already exists at ~/.config/falyx")
return None
tasks_path.write_text(GLOBAL_TEMPLATE_TASKS)
config_path.write_text(GLOBAL_CONFIG)
console.print("✅ Initialized global Falyx config at ~/.config/falyx")

5
falyx/logger.py Normal file
View File

@ -0,0 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""logger.py"""
import logging
logger = logging.getLogger("falyx")

View File

@ -1,88 +0,0 @@
import asyncio
import logging
from rich.markdown import Markdown
from falyx import Action, Falyx
from falyx.hook_manager import HookType
from falyx.debug import log_before, log_success, log_error, log_after
from falyx.themes.colors import OneColors
from falyx.utils import setup_logging
# Setup logging
setup_logging(console_log_level=logging.WARNING, json_log_to_file=True)
def main():
# Create the menu
menu = Falyx(
title=Markdown("# 🚀 Falyx CLI Demo"),
welcome_message="Welcome to Falyx!",
exit_message="Thanks for using Falyx!",
include_history_command=True,
include_help_command=True,
)
# Define async actions
async def hello():
print("👋 Hello from Falyx CLI!")
def goodbye():
print("👋 Goodbye from Falyx CLI!")
async def do_task_and_increment(counter_name: str = "tasks"):
await asyncio.sleep(3)
print("✅ Task completed.")
menu.bottom_bar.increment_total_counter(counter_name)
# Register global logging hooks
menu.hooks.register(HookType.BEFORE, log_before)
menu.hooks.register(HookType.ON_SUCCESS, log_success)
menu.hooks.register(HookType.ON_ERROR, log_error)
menu.hooks.register(HookType.AFTER, log_after)
# Add a toggle to the bottom bar
menu.add_toggle("D", "Debug Mode", state=False)
# Add a counter to the bottom bar
menu.add_total_counter("tasks", "Tasks", current=0, total=5)
# Add static text to the bottom bar
menu.add_static("env", "🌐 Local Env")
# Add commands with help_text
menu.add_command(
key="S",
description="Say Hello",
help_text="Greets the user with a friendly hello message.",
action=Action("Hello", hello),
color=OneColors.CYAN,
)
menu.add_command(
key="G",
description="Say Goodbye",
help_text="Bids farewell and thanks the user for using the app.",
action=Action("Goodbye", goodbye),
color=OneColors.MAGENTA,
)
menu.add_command(
key="T",
description="Run a Task",
aliases=["task", "run"],
help_text="Performs a task and increments the counter by 1.",
action=do_task_and_increment,
args=("tasks",),
color=OneColors.GREEN,
spinner=True,
)
asyncio.run(menu.run())
if __name__ == "__main__":
"""
Entry point for the Falyx CLI demo application.
This function initializes the menu and runs it.
"""
main()

105
falyx/menu.py Normal file
View File

@ -0,0 +1,105 @@
from __future__ import annotations
from dataclasses import dataclass
from prompt_toolkit.formatted_text import FormattedText
from falyx.action.base 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}[/]"
def render_prompt(self, key: str) -> FormattedText:
"""Render the menu option for prompt display."""
return FormattedText(
[(OneColors.WHITE, f"[{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 = {"B", "X"}
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(
"B",
MenuOption("Back", SignalAction("Back", BackSignal()), OneColors.DARK_YELLOW),
)
self._add_reserved(
"X",
MenuOption("Exit", SignalAction("Quit", QuitSignal()), OneColors.DARK_RED),
)
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 update(self, other=None, **kwargs):
"""Update the selection options with another dictionary."""
if other:
for key, option in other.items():
if not isinstance(option, MenuOption):
raise TypeError(f"Value for key '{key}' must be a SelectionOption.")
self[key] = option
for key, option in kwargs.items():
if not isinstance(option, MenuOption):
raise TypeError(f"Value for key '{key}' must be a SelectionOption.")
self[key] = option
def items(self, include_reserved: bool = True):
for key, option in super().items():
if not include_reserved and key in self.RESERVED_KEYS:
continue
yield key, option

View File

@ -1,15 +1,18 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""options_manager.py""" """options_manager.py"""
from argparse import Namespace from argparse import Namespace
from collections import defaultdict from collections import defaultdict
from typing import Any, Callable from typing import Any, Callable
from falyx.utils import logger from falyx.logger import logger
class OptionsManager: class OptionsManager:
def __init__(self, namespaces: list[tuple[str, Namespace]] = None) -> None: """OptionsManager"""
self.options = defaultdict(lambda: Namespace())
def __init__(self, namespaces: list[tuple[str, Namespace]] | None = None) -> None:
self.options: defaultdict = defaultdict(Namespace)
if namespaces: if namespaces:
for namespace_name, namespace in namespaces: for namespace_name, namespace in namespaces:
self.from_namespace(namespace, namespace_name) self.from_namespace(namespace, namespace_name)
@ -25,9 +28,7 @@ class OptionsManager:
"""Get the value of an option.""" """Get the value of an option."""
return getattr(self.options[namespace_name], option_name, default) return getattr(self.options[namespace_name], option_name, default)
def set( def set(self, option_name: str, value: Any, namespace_name: str = "cli_args") -> None:
self, option_name: str, value: Any, namespace_name: str = "cli_args"
) -> None:
"""Set the value of an option.""" """Set the value of an option."""
setattr(self.options[namespace_name], option_name, value) setattr(self.options[namespace_name], option_name, value)
@ -43,7 +44,9 @@ class OptionsManager:
f"Cannot toggle non-boolean option: '{option_name}' in '{namespace_name}'" f"Cannot toggle non-boolean option: '{option_name}' in '{namespace_name}'"
) )
self.set(option_name, not current, namespace_name=namespace_name) self.set(option_name, not current, namespace_name=namespace_name)
logger.debug(f"Toggled '{option_name}' in '{namespace_name}' to {not current}") logger.debug(
"Toggled '%s' in '%s' to %s", option_name, namespace_name, not current
)
def get_value_getter( def get_value_getter(
self, option_name: str, namespace_name: str = "cli_args" self, option_name: str, namespace_name: str = "cli_args"

View File

@ -1,100 +0,0 @@
"""parsers.py
This module contains the argument parsers used for the Falyx CLI.
"""
from argparse import ArgumentParser, HelpFormatter, Namespace
from dataclasses import asdict, dataclass
from typing import Any, Sequence
@dataclass
class FalyxParsers:
"""Defines the argument parsers for the Falyx CLI."""
root: ArgumentParser
run: ArgumentParser
run_all: ArgumentParser
preview: ArgumentParser
list: ArgumentParser
version: ArgumentParser
def parse_args(self, args: Sequence[str] | None = None) -> Namespace:
"""Parse the command line arguments."""
return self.root.parse_args(args)
def as_dict(self) -> dict[str, ArgumentParser]:
"""Convert the FalyxParsers instance to a dictionary."""
return asdict(self)
def get_parser(self, name: str) -> ArgumentParser | None:
"""Get the parser by name."""
return self.as_dict().get(name)
def get_arg_parsers(
prog: str |None = "falyx",
usage: str | None = None,
description: str | None = "Falyx CLI - Run structured async command workflows.",
epilog: str | None = None,
parents: Sequence[ArgumentParser] = [],
formatter_class: HelpFormatter = HelpFormatter,
prefix_chars: str = "-",
fromfile_prefix_chars: str | None = None,
argument_default: Any = None,
conflict_handler: str = "error",
add_help: bool = True,
allow_abbrev: bool = True,
exit_on_error: bool = True,
) -> FalyxParsers:
"""Returns the argument parser for the CLI."""
parser = ArgumentParser(
prog=prog,
usage=usage,
description=description,
epilog=epilog,
parents=parents,
formatter_class=formatter_class,
prefix_chars=prefix_chars,
fromfile_prefix_chars=fromfile_prefix_chars,
argument_default=argument_default,
conflict_handler=conflict_handler,
add_help=add_help,
allow_abbrev=allow_abbrev,
exit_on_error=exit_on_error,
)
parser.add_argument("-v", "--verbose", action="store_true", help="Enable debug logging for Falyx.")
parser.add_argument("--debug-hooks", action="store_true", help="Enable default lifecycle debug logging")
parser.add_argument("--version", action="store_true", help="Show Falyx version")
subparsers = parser.add_subparsers(dest="command")
run_parser = subparsers.add_parser("run", help="Run a specific command")
run_parser.add_argument("name", help="Key, alias, or description of the command")
run_parser.add_argument("--retries", type=int, help="Number of retries on failure", default=0)
run_parser.add_argument("--retry-delay", type=float, help="Initial delay between retries in (seconds)", default=0)
run_parser.add_argument("--retry-backoff", type=float, help="Backoff factor for retries", default=0)
run_group = run_parser.add_mutually_exclusive_group(required=False)
run_group.add_argument("-c", "--confirm", dest="force_confirm", action="store_true", help="Force confirmation prompts")
run_group.add_argument("-s", "--skip-confirm", dest="skip_confirm", action="store_true", help="Skip confirmation prompts")
run_all_parser = subparsers.add_parser("run-all", help="Run all commands with a given tag")
run_all_parser.add_argument("-t", "--tag", required=True, help="Tag to match")
run_all_parser.add_argument("--retries", type=int, help="Number of retries on failure", default=0)
run_all_parser.add_argument("--retry-delay", type=float, help="Initial delay between retries in (seconds)", default=0)
run_all_parser.add_argument("--retry-backoff", type=float, help="Backoff factor for retries", default=0)
run_all_group = run_all_parser.add_mutually_exclusive_group(required=False)
run_all_group.add_argument("-c", "--confirm", dest="force_confirm", action="store_true", help="Force confirmation prompts")
run_all_group.add_argument("-s", "--skip-confirm", dest="skip_confirm", action="store_true", help="Skip confirmation prompts")
preview_parser = subparsers.add_parser("preview", help="Preview a command without running it")
preview_parser.add_argument("name", help="Key, alias, or description of the command")
list_parser = subparsers.add_parser("list", help="List all available commands with tags")
version_parser = subparsers.add_parser("version", help="Show the Falyx version")
return FalyxParsers(
root=parser,
run=run_parser,
run_all=run_all_parser,
preview=preview_parser,
list=list_parser,
version=version_parser,
)

0
falyx/parsers/.pytyped Normal file
View File

19
falyx/parsers/__init__.py Normal file
View File

@ -0,0 +1,19 @@
"""
Falyx CLI Framework
Copyright (c) 2025 rtj.dev LLC.
Licensed under the MIT License. See LICENSE file for details.
"""
from .argparse import Argument, ArgumentAction, CommandArgumentParser
from .parsers import FalyxParsers, get_arg_parsers, get_root_parser, get_subparsers
__all__ = [
"Argument",
"ArgumentAction",
"CommandArgumentParser",
"get_arg_parsers",
"get_root_parser",
"get_subparsers",
"FalyxParsers",
]

949
falyx/parsers/argparse.py Normal file
View File

@ -0,0 +1,949 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass
from enum import Enum
from typing import Any, Iterable
from rich.console import Console
from rich.markup import escape
from rich.text import Text
from falyx.action.base import BaseAction
from falyx.exceptions import CommandArgumentError
from falyx.parsers.utils import coerce_value
from falyx.signals import HelpSignal
class ArgumentAction(Enum):
"""Defines the action to be taken when the argument is encountered."""
ACTION = "action"
STORE = "store"
STORE_TRUE = "store_true"
STORE_FALSE = "store_false"
APPEND = "append"
EXTEND = "extend"
COUNT = "count"
HELP = "help"
@classmethod
def choices(cls) -> list[ArgumentAction]:
"""Return a list of all argument actions."""
return list(cls)
def __str__(self) -> str:
"""Return the string representation of the argument action."""
return self.value
@dataclass
class Argument:
"""Represents a command-line argument."""
flags: tuple[str, ...]
dest: str # Destination name for the argument
action: ArgumentAction = (
ArgumentAction.STORE
) # Action to be taken when the argument is encountered
type: Any = str # Type of the argument (e.g., str, int, float) or callable
default: Any = None # Default value if the argument is not provided
choices: list[str] | None = None # List of valid choices for the argument
required: bool = False # True if the argument is required
help: str = "" # Help text for the 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
def get_positional_text(self) -> str:
"""Get the positional text for the argument."""
text = ""
if self.positional:
if self.choices:
text = f"{{{','.join([str(choice) for choice in self.choices])}}}"
else:
text = self.dest
return text
def get_choice_text(self) -> str:
"""Get the choice text for the argument."""
choice_text = ""
if self.choices:
choice_text = f"{{{','.join([str(choice) for choice in self.choices])}}}"
elif (
self.action
in (
ArgumentAction.STORE,
ArgumentAction.APPEND,
ArgumentAction.EXTEND,
)
and not self.positional
):
choice_text = self.dest.upper()
elif self.action in (
ArgumentAction.STORE,
ArgumentAction.APPEND,
ArgumentAction.EXTEND,
) or isinstance(self.nargs, str):
choice_text = self.dest
if self.nargs == "?":
choice_text = f"[{choice_text}]"
elif self.nargs == "*":
choice_text = f"[{choice_text} ...]"
elif self.nargs == "+":
choice_text = f"{choice_text} [{choice_text} ...]"
return choice_text
def __eq__(self, other: object) -> bool:
if not isinstance(other, Argument):
return False
return (
self.flags == other.flags
and self.dest == other.dest
and self.action == other.action
and self.type == other.type
and self.choices == other.choices
and self.required == other.required
and self.nargs == other.nargs
and self.positional == other.positional
and self.default == other.default
and self.help == other.help
)
def __hash__(self) -> int:
return hash(
(
tuple(self.flags),
self.dest,
self.action,
self.type,
tuple(self.choices or []),
self.required,
self.nargs,
self.positional,
self.default,
self.help,
)
)
class CommandArgumentParser:
"""
Custom argument parser for Falyx Commands.
It is used to create a command-line interface for Falyx
commands, allowing users to specify options and arguments
when executing commands.
It is not intended to be a full-featured replacement for
argparse, but rather a lightweight alternative for specific use
cases within the Falyx framework.
Features:
- Customizable argument parsing.
- Type coercion for arguments.
- Support for positional and keyword arguments.
- Support for default values.
- Support for boolean flags.
- Exception handling for invalid arguments.
- Render Help using Rich library.
"""
def __init__(
self,
command_key: str = "",
command_description: str = "",
command_style: str = "bold",
help_text: str = "",
help_epilog: str = "",
aliases: list[str] | None = None,
) -> None:
"""Initialize the CommandArgumentParser."""
self.console = Console(color_system="truecolor")
self.command_key: str = command_key
self.command_description: str = command_description
self.command_style: str = command_style
self.help_text: str = help_text
self.help_epilog: str = help_epilog
self.aliases: list[str] = aliases or []
self._arguments: list[Argument] = []
self._positional: dict[str, Argument] = {}
self._keyword: dict[str, Argument] = {}
self._keyword_list: list[Argument] = []
self._flag_map: dict[str, Argument] = {}
self._dest_set: set[str] = set()
self._add_help()
def _add_help(self):
"""Add help argument to the parser."""
self.add_argument(
"-h",
"--help",
action=ArgumentAction.HELP,
help="Show this help message.",
dest="help",
)
def _is_positional(self, flags: tuple[str, ...]) -> bool:
"""Check if the flags are positional."""
positional = False
if any(not flag.startswith("-") for flag in flags):
positional = True
if positional and len(flags) > 1:
raise CommandArgumentError("Positional arguments cannot have multiple flags")
return positional
def _get_dest_from_flags(self, flags: tuple[str, ...], dest: str | None) -> str:
"""Convert flags to a destination name."""
if dest:
if not dest.replace("_", "").isalnum():
raise CommandArgumentError(
"dest must be a valid identifier (letters, digits, and underscores only)"
)
if dest[0].isdigit():
raise CommandArgumentError("dest must not start with a digit")
return dest
dest = None
for flag in flags:
if flag.startswith("--"):
dest = flag.lstrip("-").replace("-", "_").lower()
break
elif flag.startswith("-"):
dest = flag.lstrip("-").replace("-", "_").lower()
else:
dest = flag.replace("-", "_").lower()
assert dest is not None, "dest should not be None"
if not dest.replace("_", "").isalnum():
raise CommandArgumentError(
"dest must be a valid identifier (letters, digits, and underscores only)"
)
if dest[0].isdigit():
raise CommandArgumentError("dest must not start with a digit")
return dest
def _determine_required(
self, required: bool, positional: bool, nargs: int | str | None
) -> bool:
"""Determine if the argument is required."""
if required:
return True
if positional:
assert (
nargs is None
or isinstance(nargs, int)
or isinstance(nargs, str)
and nargs in ("+", "*", "?")
), f"Invalid nargs value: {nargs}"
if isinstance(nargs, int):
return nargs > 0
elif isinstance(nargs, str):
if nargs in ("+"):
return True
elif nargs in ("*", "?"):
return False
else:
return True
return required
def _validate_nargs(
self, nargs: int | str | None, action: ArgumentAction
) -> int | str | None:
if action in (
ArgumentAction.STORE_FALSE,
ArgumentAction.STORE_TRUE,
ArgumentAction.COUNT,
ArgumentAction.HELP,
):
if nargs is not None:
raise CommandArgumentError(
f"nargs cannot be specified for {action} actions"
)
return None
if nargs is None:
return None
allowed_nargs = ("?", "*", "+")
if isinstance(nargs, int):
if nargs <= 0:
raise CommandArgumentError("nargs must be a positive integer")
elif isinstance(nargs, str):
if nargs not in allowed_nargs:
raise CommandArgumentError(f"Invalid nargs value: {nargs}")
else:
raise CommandArgumentError(f"nargs must be an int or one of {allowed_nargs}")
return nargs
def _normalize_choices(
self, choices: Iterable | None, expected_type: Any
) -> list[Any]:
if choices is not None:
if isinstance(choices, dict):
raise CommandArgumentError("choices cannot be a dict")
try:
choices = list(choices)
except TypeError:
raise CommandArgumentError(
"choices must be iterable (like list, tuple, or set)"
)
else:
choices = []
for choice in choices:
if not isinstance(choice, expected_type):
try:
coerce_value(choice, expected_type)
except Exception as error:
raise CommandArgumentError(
f"Invalid choice {choice!r}: not coercible to {expected_type.__name__} error: {error}"
) from error
return choices
def _validate_default_type(
self, default: Any, expected_type: type, dest: str
) -> None:
"""Validate the default value type."""
if default is not None and not isinstance(default, expected_type):
try:
coerce_value(default, expected_type)
except Exception as error:
raise CommandArgumentError(
f"Default value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__} error: {error}"
) from error
def _validate_default_list_type(
self, default: list[Any], expected_type: type, dest: str
) -> None:
if isinstance(default, list):
for item in default:
if not isinstance(item, expected_type):
try:
coerce_value(item, expected_type)
except Exception as error:
raise CommandArgumentError(
f"Default list value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__} error: {error}"
) from error
def _validate_resolver(
self, action: ArgumentAction, resolver: BaseAction | None
) -> BaseAction | None:
"""Validate the action object."""
if action != ArgumentAction.ACTION and resolver is None:
return None
elif action == ArgumentAction.ACTION and resolver is None:
raise CommandArgumentError("resolver must be provided for ACTION action")
elif action != ArgumentAction.ACTION and resolver is not None:
raise CommandArgumentError(
f"resolver should not be provided for action {action}"
)
if not isinstance(resolver, BaseAction):
raise CommandArgumentError("resolver must be an instance of BaseAction")
return resolver
def _validate_action(
self, action: ArgumentAction | str, positional: bool
) -> ArgumentAction:
if not isinstance(action, ArgumentAction):
try:
action = ArgumentAction(action)
except ValueError:
raise CommandArgumentError(
f"Invalid action '{action}' is not a valid ArgumentAction"
)
if action in (
ArgumentAction.STORE_TRUE,
ArgumentAction.STORE_FALSE,
ArgumentAction.COUNT,
ArgumentAction.HELP,
):
if positional:
raise CommandArgumentError(
f"Action '{action}' cannot be used with positional arguments"
)
return action
def _resolve_default(
self,
default: Any,
action: ArgumentAction,
nargs: str | int | None,
) -> Any:
"""Get the default value for the argument."""
if default is None:
if action == ArgumentAction.STORE_TRUE:
return False
elif action == ArgumentAction.STORE_FALSE:
return True
elif action == ArgumentAction.COUNT:
return 0
elif action in (ArgumentAction.APPEND, ArgumentAction.EXTEND):
return []
elif isinstance(nargs, int):
return []
elif nargs in ("+", "*"):
return []
else:
return None
return default
def _validate_flags(self, flags: tuple[str, ...]) -> None:
"""Validate the flags provided for the argument."""
if not flags:
raise CommandArgumentError("No flags provided")
for flag in flags:
if not isinstance(flag, str):
raise CommandArgumentError(f"Flag '{flag}' must be a string")
if flag.startswith("--") and len(flag) < 3:
raise CommandArgumentError(
f"Flag '{flag}' must be at least 3 characters long"
)
if flag.startswith("-") and not flag.startswith("--") and len(flag) > 2:
raise CommandArgumentError(
f"Flag '{flag}' must be a single character or start with '--'"
)
def add_argument(
self,
*flags,
action: str | ArgumentAction = "store",
nargs: int | str | None = None,
default: Any = None,
type: Any = str,
choices: Iterable | None = None,
required: bool = False,
help: str = "",
dest: str | None = None,
resolver: BaseAction | None = None,
) -> None:
"""Add an argument to the parser.
For `ArgumentAction.ACTION`, `nargs` and `type` determine how many and what kind
of inputs are passed to the `resolver`.
The return value of the `resolver` is used directly (no type coercion is applied).
Validation, structure, and post-processing should be handled within the `resolver`.
Args:
name or flags: Either a name or prefixed flags (e.g. 'faylx', '-f', '--falyx').
action: The action to be taken when the argument is encountered.
nargs: The number of arguments expected.
default: The default value if the argument is not provided.
type: The type to which the command-line argument should be converted.
choices: A container of the allowable values for the argument.
required: Whether or not the argument is required.
help: A brief description of the argument.
dest: The name of the attribute to be added to the object returned by parse_args().
resolver: A BaseAction called with optional nargs specified parsed arguments.
"""
expected_type = type
self._validate_flags(flags)
positional = self._is_positional(flags)
dest = self._get_dest_from_flags(flags, dest)
if dest in self._dest_set:
raise CommandArgumentError(
f"Destination '{dest}' is already defined.\n"
"Merging multiple arguments into the same dest (e.g. positional + flagged) "
"is not supported. Define a unique 'dest' for each argument."
)
action = self._validate_action(action, positional)
resolver = self._validate_resolver(action, resolver)
nargs = self._validate_nargs(nargs, action)
default = self._resolve_default(default, action, nargs)
if (
action in (ArgumentAction.STORE, ArgumentAction.APPEND, ArgumentAction.EXTEND)
and default is not None
):
if isinstance(default, list):
self._validate_default_list_type(default, expected_type, dest)
else:
self._validate_default_type(default, expected_type, dest)
choices = self._normalize_choices(choices, expected_type)
if default is not None and choices and default not in choices:
raise CommandArgumentError(
f"Default value '{default}' not in allowed choices: {choices}"
)
required = self._determine_required(required, positional, nargs)
argument = Argument(
flags=flags,
dest=dest,
action=action,
type=expected_type,
default=default,
choices=choices,
required=required,
help=help,
nargs=nargs,
positional=positional,
resolver=resolver,
)
for flag in flags:
if flag in self._flag_map:
existing = self._flag_map[flag]
raise CommandArgumentError(
f"Flag '{flag}' is already used by argument '{existing.dest}'"
)
for flag in flags:
self._flag_map[flag] = argument
if not positional:
self._keyword[flag] = argument
self._dest_set.add(dest)
self._arguments.append(argument)
if positional:
self._positional[dest] = argument
else:
self._keyword_list.append(argument)
def get_argument(self, dest: str) -> Argument | None:
return next((a for a in self._arguments if a.dest == dest), None)
def to_definition_list(self) -> list[dict[str, Any]]:
defs = []
for arg in self._arguments:
defs.append(
{
"flags": arg.flags,
"dest": arg.dest,
"action": arg.action,
"type": arg.type,
"choices": arg.choices,
"required": arg.required,
"nargs": arg.nargs,
"positional": arg.positional,
"default": arg.default,
"help": arg.help,
}
)
return defs
def _consume_nargs(
self, args: list[str], start: int, spec: Argument
) -> tuple[list[str], int]:
assert (
spec.nargs is None
or isinstance(spec.nargs, int)
or isinstance(spec.nargs, str)
and spec.nargs in ("+", "*", "?")
), f"Invalid nargs value: {spec.nargs}"
values = []
i = start
if isinstance(spec.nargs, int):
values = args[i : i + spec.nargs]
return values, i + spec.nargs
elif spec.nargs == "+":
if i >= len(args):
raise CommandArgumentError(
f"Expected at least one value for '{spec.dest}'"
)
while i < len(args) and not args[i].startswith("-"):
values.append(args[i])
i += 1
assert values, "Expected at least one value for '+' nargs: shouldn't happen"
return values, i
elif spec.nargs == "*":
while i < len(args) and not args[i].startswith("-"):
values.append(args[i])
i += 1
return values, i
elif spec.nargs == "?":
if i < len(args) and not args[i].startswith("-"):
return [args[i]], i + 1
return [], i
elif spec.nargs is None:
if i < len(args) and not args[i].startswith("-"):
return [args[i]], i + 1
return [], i
assert False, "Invalid nargs value: shouldn't happen"
async def _consume_all_positional_args(
self,
args: list[str],
result: dict[str, Any],
positional_args: list[Argument],
consumed_positional_indicies: set[int],
) -> int:
remaining_positional_args = [
(j, spec)
for j, spec in enumerate(positional_args)
if j not in consumed_positional_indicies
]
i = 0
for j, spec in remaining_positional_args:
# estimate how many args the remaining specs might need
is_last = j == len(positional_args) - 1
remaining = len(args) - i
min_required = 0
for next_spec in positional_args[j + 1 :]:
assert (
next_spec.nargs is None
or isinstance(next_spec.nargs, int)
or isinstance(next_spec.nargs, str)
and next_spec.nargs in ("+", "*", "?")
), f"Invalid nargs value: {spec.nargs}"
if next_spec.nargs is None:
min_required += 1
elif isinstance(next_spec.nargs, int):
min_required += next_spec.nargs
elif next_spec.nargs == "+":
min_required += 1
elif next_spec.nargs == "?":
min_required += 0
elif next_spec.nargs == "*":
min_required += 0
slice_args = args[i:] if is_last else args[i : i + (remaining - min_required)]
values, new_i = self._consume_nargs(slice_args, 0, spec)
i += new_i
try:
typed = [coerce_value(value, spec.type) for value in values]
except Exception as error:
raise CommandArgumentError(
f"Invalid value for '{spec.dest}': {error}"
) from error
if spec.action == ArgumentAction.ACTION:
assert isinstance(
spec.resolver, BaseAction
), "resolver should be an instance of BaseAction"
try:
result[spec.dest] = await spec.resolver(*typed)
except Exception as error:
raise CommandArgumentError(
f"[{spec.dest}] Action failed: {error}"
) from error
elif spec.action == ArgumentAction.APPEND:
assert result.get(spec.dest) is not None, "dest should not be None"
if spec.nargs is None:
result[spec.dest].append(typed[0])
else:
result[spec.dest].append(typed)
elif spec.action == ArgumentAction.EXTEND:
assert result.get(spec.dest) is not None, "dest should not be None"
result[spec.dest].extend(typed)
elif spec.nargs in (None, 1, "?"):
result[spec.dest] = typed[0] if len(typed) == 1 else typed
else:
result[spec.dest] = typed
if spec.nargs not in ("*", "+"):
consumed_positional_indicies.add(j)
if i < len(args):
plural = "s" if len(args[i:]) > 1 else ""
raise CommandArgumentError(
f"Unexpected positional argument{plural}: {', '.join(args[i:])}"
)
return i
def _expand_posix_bundling(self, args: list[str]) -> list[str]:
"""Expand POSIX-style bundled arguments into separate arguments."""
expanded = []
for token in args:
if token.startswith("-") and not token.startswith("--") and len(token) > 2:
# POSIX bundle
# e.g. -abc -> -a -b -c
for char in token[1:]:
flag = f"-{char}"
arg = self._flag_map.get(flag)
if not arg:
raise CommandArgumentError(f"Unrecognized option: {flag}")
expanded.append(flag)
else:
expanded.append(token)
return expanded
async def parse_args(
self, args: list[str] | None = None, from_validate: bool = False
) -> dict[str, Any]:
"""Parse Falyx Command arguments."""
if args is None:
args = []
args = self._expand_posix_bundling(args)
result = {arg.dest: deepcopy(arg.default) for arg in self._arguments}
positional_args = [arg for arg in self._arguments if arg.positional]
consumed_positional_indices: set[int] = set()
consumed_indices: set[int] = set()
i = 0
while i < len(args):
token = args[i]
if token in self._keyword:
spec = self._keyword[token]
action = spec.action
if action == ArgumentAction.HELP:
if not from_validate:
self.render_help()
raise HelpSignal()
elif action == ArgumentAction.ACTION:
assert isinstance(
spec.resolver, BaseAction
), "resolver should be an instance of BaseAction"
values, new_i = self._consume_nargs(args, i + 1, spec)
try:
typed_values = [
coerce_value(value, spec.type) for value in values
]
except ValueError as error:
raise CommandArgumentError(
f"Invalid value for '{spec.dest}': {error}"
) from error
try:
result[spec.dest] = await spec.resolver(*typed_values)
except Exception as error:
raise CommandArgumentError(
f"[{spec.dest}] Action failed: {error}"
) from error
consumed_indices.update(range(i, new_i))
i = new_i
elif action == ArgumentAction.STORE_TRUE:
result[spec.dest] = True
consumed_indices.add(i)
i += 1
elif action == ArgumentAction.STORE_FALSE:
result[spec.dest] = False
consumed_indices.add(i)
i += 1
elif action == ArgumentAction.COUNT:
result[spec.dest] = result.get(spec.dest, 0) + 1
consumed_indices.add(i)
i += 1
elif action == ArgumentAction.APPEND:
assert result.get(spec.dest) is not None, "dest should not be None"
values, new_i = self._consume_nargs(args, i + 1, spec)
try:
typed_values = [
coerce_value(value, spec.type) for value in values
]
except ValueError as error:
raise CommandArgumentError(
f"Invalid value for '{spec.dest}': {error}"
) from error
if spec.nargs is None:
result[spec.dest].append(spec.type(values[0]))
else:
result[spec.dest].append(typed_values)
consumed_indices.update(range(i, new_i))
i = new_i
elif action == ArgumentAction.EXTEND:
assert result.get(spec.dest) is not None, "dest should not be None"
values, new_i = self._consume_nargs(args, i + 1, spec)
try:
typed_values = [
coerce_value(value, spec.type) for value in values
]
except ValueError as error:
raise CommandArgumentError(
f"Invalid value for '{spec.dest}': {error}"
) from error
result[spec.dest].extend(typed_values)
consumed_indices.update(range(i, new_i))
i = new_i
else:
values, new_i = self._consume_nargs(args, i + 1, spec)
try:
typed_values = [
coerce_value(value, spec.type) for value in values
]
except ValueError as error:
raise CommandArgumentError(
f"Invalid value for '{spec.dest}': {error}"
) from error
if not typed_values and spec.nargs not in ("*", "?"):
raise CommandArgumentError(
f"Expected at least one value for '{spec.dest}'"
)
if (
spec.nargs in (None, 1, "?")
and spec.action != ArgumentAction.APPEND
):
result[spec.dest] = (
typed_values[0] if len(typed_values) == 1 else typed_values
)
else:
result[spec.dest] = typed_values
consumed_indices.update(range(i, new_i))
i = new_i
elif token.startswith("-"):
# Handle unrecognized option
raise CommandArgumentError(f"Unrecognized flag: {token}")
else:
# Get the next flagged argument index if it exists
next_flagged_index = -1
for index, arg in enumerate(args[i:], start=i):
if arg.startswith("-"):
next_flagged_index = index
break
if next_flagged_index == -1:
next_flagged_index = len(args)
args_consumed = await self._consume_all_positional_args(
args[i:next_flagged_index],
result,
positional_args,
consumed_positional_indices,
)
i += args_consumed
# Required validation
for spec in self._arguments:
if spec.dest == "help":
continue
if spec.required and not result.get(spec.dest):
raise CommandArgumentError(f"Missing required argument: {spec.dest}")
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}"
)
if spec.action == ArgumentAction.ACTION:
continue
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"
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}"
)
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}"
)
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])}"
)
result.pop("help", None)
return result
async def parse_args_split(
self, args: list[str], from_validate: bool = False
) -> tuple[tuple[Any, ...], dict[str, Any]]:
"""
Returns:
tuple[args, kwargs] - Positional arguments in defined order,
followed by keyword argument mapping.
"""
parsed = await self.parse_args(args, from_validate)
args_list = []
kwargs_dict = {}
for arg in self._arguments:
if arg.dest == "help":
continue
if arg.positional:
args_list.append(parsed[arg.dest])
else:
kwargs_dict[arg.dest] = parsed[arg.dest]
return tuple(args_list), kwargs_dict
def get_options_text(self, plain_text=False) -> str:
# Options
# Add all keyword arguments to the options list
options_list = []
for arg in self._keyword_list:
choice_text = arg.get_choice_text()
if choice_text:
options_list.extend([f"[{arg.flags[0]} {choice_text}]"])
else:
options_list.extend([f"[{arg.flags[0]}]"])
# Add positional arguments to the options list
for arg in self._positional.values():
choice_text = arg.get_choice_text()
if isinstance(arg.nargs, int):
choice_text = " ".join([choice_text] * arg.nargs)
if plain_text:
options_list.append(choice_text)
else:
options_list.append(escape(choice_text))
return " ".join(options_list)
def get_command_keys_text(self, plain_text=False) -> str:
if plain_text:
command_keys = " | ".join(
[f"{self.command_key}"] + [f"{alias}" for alias in self.aliases]
)
else:
command_keys = " | ".join(
[f"[{self.command_style}]{self.command_key}[/{self.command_style}]"]
+ [
f"[{self.command_style}]{alias}[/{self.command_style}]"
for alias in self.aliases
]
)
return command_keys
def get_usage(self, plain_text=False) -> str:
"""Get the usage text for the command."""
command_keys = self.get_command_keys_text(plain_text)
options_text = self.get_options_text(plain_text)
if options_text:
return f"{command_keys} {options_text}"
return command_keys
def render_help(self) -> None:
usage = self.get_usage()
self.console.print(f"[bold]usage: {usage}[/bold]\n")
# Description
if self.help_text:
self.console.print(self.help_text + "\n")
# Arguments
if self._arguments:
if self._positional:
self.console.print("[bold]positional:[/bold]")
for arg in self._positional.values():
flags = arg.get_positional_text()
arg_line = Text(f" {flags:<30} ")
help_text = arg.help or ""
arg_line.append(help_text)
self.console.print(arg_line)
self.console.print("[bold]options:[/bold]")
for arg in self._keyword_list:
flags = ", ".join(arg.flags)
flags_choice = f"{flags} {arg.get_choice_text()}"
arg_line = Text(f" {flags_choice:<30} ")
help_text = arg.help or ""
arg_line.append(help_text)
self.console.print(arg_line)
# Epilog
if self.help_epilog:
self.console.print("\n" + self.help_epilog, style="dim")
def __eq__(self, other: object) -> bool:
if not isinstance(other, CommandArgumentParser):
return False
def sorted_args(parser):
return sorted(parser._arguments, key=lambda a: a.dest)
return sorted_args(self) == sorted_args(other)
def __hash__(self) -> int:
return hash(tuple(sorted(self._arguments, key=lambda a: a.dest)))
def __str__(self) -> str:
positional = sum(arg.positional for arg in self._arguments)
required = sum(arg.required for arg in self._arguments)
return (
f"CommandArgumentParser(args={len(self._arguments)}, "
f"flags={len(self._flag_map)}, keywords={len(self._keyword)}, "
f"positional={positional}, required={required})"
)
def __repr__(self) -> str:
return str(self)

272
falyx/parsers/parsers.py Normal file
View File

@ -0,0 +1,272 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""parsers.py
This module contains the argument parsers used for the Falyx CLI.
"""
from argparse import (
REMAINDER,
ArgumentParser,
Namespace,
RawDescriptionHelpFormatter,
_SubParsersAction,
)
from dataclasses import asdict, dataclass
from typing import Any, Sequence
from falyx.command import Command
@dataclass
class FalyxParsers:
"""Defines the argument parsers for the Falyx CLI."""
root: ArgumentParser
subparsers: _SubParsersAction
run: ArgumentParser
run_all: ArgumentParser
preview: ArgumentParser
list: ArgumentParser
version: ArgumentParser
def parse_args(self, args: Sequence[str] | None = None) -> Namespace:
"""Parse the command line arguments."""
return self.root.parse_args(args)
def as_dict(self) -> dict[str, ArgumentParser]:
"""Convert the FalyxParsers instance to a dictionary."""
return asdict(self)
def get_parser(self, name: str) -> ArgumentParser | None:
"""Get the parser by name."""
return self.as_dict().get(name)
def get_root_parser(
prog: str | None = "falyx",
usage: str | None = None,
description: str | None = "Falyx CLI - Run structured async command workflows.",
epilog: (
str | None
) = "Tip: Use 'falyx run ?[COMMAND]' to preview any command from the CLI.",
parents: Sequence[ArgumentParser] | None = None,
prefix_chars: str = "-",
fromfile_prefix_chars: str | None = None,
argument_default: Any = None,
conflict_handler: str = "error",
add_help: bool = True,
allow_abbrev: bool = True,
exit_on_error: bool = True,
) -> ArgumentParser:
parser = ArgumentParser(
prog=prog,
usage=usage,
description=description,
epilog=epilog,
parents=parents if parents else [],
prefix_chars=prefix_chars,
fromfile_prefix_chars=fromfile_prefix_chars,
argument_default=argument_default,
conflict_handler=conflict_handler,
add_help=add_help,
allow_abbrev=allow_abbrev,
exit_on_error=exit_on_error,
)
parser.add_argument(
"--never-prompt",
action="store_true",
help="Run in non-interactive mode with all prompts bypassed.",
)
parser.add_argument(
"-v", "--verbose", action="store_true", help="Enable debug logging for Falyx."
)
parser.add_argument(
"--debug-hooks",
action="store_true",
help="Enable default lifecycle debug logging",
)
parser.add_argument("--version", action="store_true", help="Show Falyx version")
return parser
def get_subparsers(
parser: ArgumentParser,
title: str = "Falyx Commands",
description: str | None = "Available commands for the Falyx CLI.",
) -> _SubParsersAction:
"""Create and return a subparsers action for the given parser."""
if not isinstance(parser, ArgumentParser):
raise TypeError("parser must be an instance of ArgumentParser")
subparsers = parser.add_subparsers(
title=title,
description=description,
metavar="COMMAND",
dest="command",
)
return subparsers
def get_arg_parsers(
prog: str | None = "falyx",
usage: str | None = None,
description: str | None = "Falyx CLI - Run structured async command workflows.",
epilog: (
str | None
) = "Tip: Use 'falyx run ?[COMMAND]' to preview any command from the CLI.",
parents: Sequence[ArgumentParser] | None = None,
prefix_chars: str = "-",
fromfile_prefix_chars: str | None = None,
argument_default: Any = None,
conflict_handler: str = "error",
add_help: bool = True,
allow_abbrev: bool = True,
exit_on_error: bool = True,
commands: dict[str, Command] | None = None,
root_parser: ArgumentParser | None = None,
subparsers: _SubParsersAction | None = None,
) -> FalyxParsers:
"""Returns the argument parser for the CLI."""
if root_parser is None:
parser = get_root_parser(
prog=prog,
usage=usage,
description=description,
epilog=epilog,
parents=parents,
prefix_chars=prefix_chars,
fromfile_prefix_chars=fromfile_prefix_chars,
argument_default=argument_default,
conflict_handler=conflict_handler,
add_help=add_help,
allow_abbrev=allow_abbrev,
exit_on_error=exit_on_error,
)
else:
if not isinstance(root_parser, ArgumentParser):
raise TypeError("root_parser must be an instance of ArgumentParser")
parser = root_parser
if subparsers is None:
subparsers = get_subparsers(parser)
if not isinstance(subparsers, _SubParsersAction):
raise TypeError("subparsers must be an instance of _SubParsersAction")
run_description = ["Run a command by its key or alias.\n"]
run_description.append("commands:")
if isinstance(commands, dict):
for command in commands.values():
run_description.append(command.usage)
command_description = command.description or command.help_text
run_description.append(f"{' '*24}{command_description}")
run_epilog = (
"Tip: Use 'falyx run ?[COMMAND]' to preview commands by their key or alias."
)
run_parser = subparsers.add_parser(
"run",
help="Run a specific command",
description="\n".join(run_description),
epilog=run_epilog,
formatter_class=RawDescriptionHelpFormatter,
)
run_parser.add_argument(
"name", help="Run a command by its key or alias", metavar="COMMAND"
)
run_parser.add_argument(
"--summary",
action="store_true",
help="Print an execution summary after command completes",
)
run_parser.add_argument(
"--retries", type=int, help="Number of retries on failure", default=0
)
run_parser.add_argument(
"--retry-delay",
type=float,
help="Initial delay between retries in (seconds)",
default=0,
)
run_parser.add_argument(
"--retry-backoff", type=float, help="Backoff factor for retries", default=0
)
run_group = run_parser.add_mutually_exclusive_group(required=False)
run_group.add_argument(
"-c",
"--confirm",
dest="force_confirm",
action="store_true",
help="Force confirmation prompts",
)
run_group.add_argument(
"-s",
"--skip-confirm",
dest="skip_confirm",
action="store_true",
help="Skip confirmation prompts",
)
run_parser.add_argument(
"command_args",
nargs=REMAINDER,
help="Arguments to pass to the command (if applicable)",
metavar="ARGS",
)
run_all_parser = subparsers.add_parser(
"run-all", help="Run all commands with a given tag"
)
run_all_parser.add_argument("-t", "--tag", required=True, help="Tag to match")
run_all_parser.add_argument(
"--summary",
action="store_true",
help="Print a summary after all tagged commands run",
)
run_all_parser.add_argument(
"--retries", type=int, help="Number of retries on failure", default=0
)
run_all_parser.add_argument(
"--retry-delay",
type=float,
help="Initial delay between retries in (seconds)",
default=0,
)
run_all_parser.add_argument(
"--retry-backoff", type=float, help="Backoff factor for retries", default=0
)
run_all_group = run_all_parser.add_mutually_exclusive_group(required=False)
run_all_group.add_argument(
"-c",
"--confirm",
dest="force_confirm",
action="store_true",
help="Force confirmation prompts",
)
run_all_group.add_argument(
"-s",
"--skip-confirm",
dest="skip_confirm",
action="store_true",
help="Skip confirmation prompts",
)
preview_parser = subparsers.add_parser(
"preview", help="Preview a command without running it"
)
preview_parser.add_argument("name", help="Key, alias, or description of the command")
list_parser = subparsers.add_parser(
"list", help="List all available commands with tags"
)
list_parser.add_argument(
"-t", "--tag", help="Filter commands by tag (case-insensitive)", default=None
)
version_parser = subparsers.add_parser("version", help="Show the Falyx version")
return FalyxParsers(
root=parser,
subparsers=subparsers,
run=run_parser,
run_all=run_all_parser,
preview=preview_parser,
list=list_parser,
version=version_parser,
)

View File

@ -0,0 +1,80 @@
import inspect
from typing import Any, Callable
from falyx.logger import logger
def infer_args_from_func(
func: Callable[[Any], Any] | None,
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
"""
Infer argument definitions from a callable's signature.
Returns a list of kwargs suitable for CommandArgumentParser.add_argument.
"""
if not callable(func):
logger.debug("Provided argument is not callable: %s", func)
return []
arg_metadata = arg_metadata or {}
signature = inspect.signature(func)
arg_defs = []
for name, param in signature.parameters.items():
raw_metadata = arg_metadata.get(name, {})
metadata = (
{"help": raw_metadata} if isinstance(raw_metadata, str) else raw_metadata
)
if param.kind not in (
inspect.Parameter.POSITIONAL_ONLY,
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.KEYWORD_ONLY,
):
continue
if metadata.get("type"):
arg_type = metadata["type"]
else:
arg_type = (
param.annotation
if param.annotation is not inspect.Parameter.empty
else str
)
if isinstance(arg_type, str):
arg_type = str
default = param.default if param.default is not inspect.Parameter.empty else None
is_required = param.default is inspect.Parameter.empty
if is_required:
flags = [f"{name.replace('_', '-')}"]
else:
flags = [f"--{name.replace('_', '-')}"]
action = "store"
nargs: int | str | None = None
if arg_type is bool:
if param.default is False:
action = "store_true"
else:
action = "store_false"
if arg_type is list:
action = "append"
if is_required:
nargs = "+"
else:
nargs = "*"
arg_defs.append(
{
"flags": flags,
"dest": name,
"type": arg_type,
"default": default,
"required": is_required,
"nargs": nargs,
"action": action,
"help": metadata.get("help", ""),
"choices": metadata.get("choices"),
}
)
return arg_defs

97
falyx/parsers/utils.py Normal file
View File

@ -0,0 +1,97 @@
import types
from datetime import datetime
from enum import EnumMeta
from typing import Any, Literal, Union, get_args, get_origin
from dateutil import parser as date_parser
from falyx.action.base import BaseAction
from falyx.logger import logger
from falyx.parsers.signature import infer_args_from_func
def coerce_bool(value: str) -> bool:
if isinstance(value, bool):
return value
value = value.strip().lower()
if value in {"true", "1", "yes", "on"}:
return True
elif value in {"false", "0", "no", "off"}:
return False
return bool(value)
def coerce_enum(value: Any, enum_type: EnumMeta) -> Any:
if isinstance(value, enum_type):
return value
if isinstance(value, str):
try:
return enum_type[value]
except KeyError:
pass
base_type = type(next(iter(enum_type)).value)
print(base_type)
try:
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}")
def coerce_value(value: str, target_type: type) -> Any:
origin = get_origin(target_type)
args = get_args(target_type)
if origin is Literal:
if value not in args:
raise ValueError(
f"Value '{value}' is not a valid literal for type {target_type}"
)
return value
if isinstance(target_type, types.UnionType) or get_origin(target_type) is Union:
for arg in args:
try:
return coerce_value(value, arg)
except Exception:
continue
raise ValueError(f"Value '{value}' could not be coerced to any of {args!r}")
if isinstance(target_type, EnumMeta):
return coerce_enum(value, target_type)
if target_type is bool:
return coerce_bool(value)
if target_type is datetime:
try:
return date_parser.parse(value)
except ValueError as e:
raise ValueError(f"Value '{value}' could not be parsed as a datetime") from e
return target_type(value)
def same_argument_definitions(
actions: list[Any],
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
) -> list[dict[str, Any]] | None:
arg_sets = []
for action in actions:
if isinstance(action, BaseAction):
infer_target, _ = action.get_infer_target()
arg_defs = infer_args_from_func(infer_target, arg_metadata)
elif callable(action):
arg_defs = infer_args_from_func(action, arg_metadata)
else:
logger.debug("Auto args unsupported for action: %s", action)
return None
arg_sets.append(arg_defs)
first = arg_sets[0]
if all(arg_set == first for arg_set in arg_sets[1:]):
return first
return None

48
falyx/prompt_utils.py Normal file
View File

@ -0,0 +1,48 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""prompt_utils.py"""
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import (
AnyFormattedText,
FormattedText,
merge_formatted_text,
)
from falyx.options_manager import OptionsManager
from falyx.themes import OneColors
from falyx.validators import yes_no_validator
def should_prompt_user(
*,
confirm: bool,
options: OptionsManager,
namespace: str = "cli_args",
):
"""
Determine whether to prompt the user for confirmation based on command
and global options.
"""
never_prompt = options.get("never_prompt", False, namespace)
force_confirm = options.get("force_confirm", False, namespace)
skip_confirm = options.get("skip_confirm", False, namespace)
if never_prompt or skip_confirm:
return False
return confirm or force_confirm
async def confirm_async(
message: AnyFormattedText = "Are you sure?",
prefix: AnyFormattedText = FormattedText([(OneColors.CYAN, "")]),
suffix: AnyFormattedText = FormattedText([(OneColors.LIGHT_YELLOW_b, " [Y/n] > ")]),
session: PromptSession | None = None,
) -> bool:
"""Prompt the user with a yes/no async confirmation and return True for 'Y'."""
session = session or PromptSession()
merged_message: AnyFormattedText = merge_formatted_text([prefix, message, suffix])
answer = await session.prompt_async(
merged_message,
validator=yes_no_validator(),
)
return answer.upper() == "Y"

17
falyx/protocols.py Normal file
View File

@ -0,0 +1,17 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""protocols.py"""
from __future__ import annotations
from typing import Any, Awaitable, Protocol, runtime_checkable
from falyx.action.base import BaseAction
@runtime_checkable
class ActionFactoryProtocol(Protocol):
async def __call__(self, *args: Any, **kwargs: Any) -> Awaitable[BaseAction]: ...
@runtime_checkable
class ArgParserProtocol(Protocol):
def __call__(self, args: list[str]) -> tuple[tuple, dict]: ...

View File

@ -1,18 +1,32 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""retry.py""" """retry.py"""
from __future__ import annotations
import asyncio import asyncio
import random
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.utils import logger from falyx.logger import logger
class RetryPolicy(BaseModel): class RetryPolicy(BaseModel):
"""RetryPolicy"""
max_retries: int = Field(default=3, ge=0) max_retries: int = Field(default=3, ge=0)
delay: float = Field(default=1.0, ge=0.0) delay: float = Field(default=1.0, ge=0.0)
backoff: float = Field(default=2.0, ge=1.0) backoff: float = Field(default=2.0, ge=1.0)
jitter: float = Field(default=0.0, ge=0.0)
enabled: bool = False enabled: bool = False
def enable_policy(self) -> None:
"""
Enable the retry policy.
:return: None
"""
self.enabled = True
def is_active(self) -> bool: def is_active(self) -> bool:
""" """
Check if the retry policy is active. Check if the retry policy is active.
@ -22,18 +36,28 @@ class RetryPolicy(BaseModel):
class RetryHandler: class RetryHandler:
def __init__(self, policy: RetryPolicy=RetryPolicy()): """RetryHandler class to manage retry policies for actions."""
def __init__(self, policy: RetryPolicy = RetryPolicy()):
self.policy = policy self.policy = policy
def enable_policy(self, backoff=2, max_retries=3, delay=1): def enable_policy(
self,
max_retries: int = 3,
delay: float = 1.0,
backoff: float = 2.0,
jitter: float = 0.0,
) -> None:
self.policy.enabled = True self.policy.enabled = True
self.policy.max_retries = max_retries self.policy.max_retries = max_retries
self.policy.delay = delay self.policy.delay = delay
self.policy.backoff = backoff self.policy.backoff = backoff
logger.info(f"🔄 Retry policy enabled: {self.policy}") self.policy.jitter = jitter
logger.info("Retry policy enabled: %s", self.policy)
async def retry_on_error(self, context: ExecutionContext): async def retry_on_error(self, context: ExecutionContext) -> None:
from falyx.action import Action from falyx.action import Action
name = context.name name = context.name
error = context.exception error = context.exception
target = context.action target = context.action
@ -43,36 +67,55 @@ class RetryHandler:
last_error = error last_error = error
if not target: if not target:
logger.warning(f"[{name}] ⚠️ No action target. Cannot retry.") logger.warning("[%s] No action target. Cannot retry.", name)
return return None
if not isinstance(target, Action): if not isinstance(target, Action):
logger.warning(f"[{name}] ❌ RetryHandler only supports only supports Action objects.") logger.warning(
return "[%s] RetryHandler only supports only supports Action objects.", name
)
return None
if not getattr(target, "is_retryable", False): if not getattr(target, "is_retryable", False):
logger.warning(f"[{name}] ❌ Not retryable.") logger.warning("[%s] Not retryable.", name)
return return None
if not self.policy.enabled: if not self.policy.enabled:
logger.warning(f"[{name}] ❌ Retry policy is disabled.") logger.warning("[%s] Retry policy is disabled.", name)
return return None
while retries_done < self.policy.max_retries: while retries_done < self.policy.max_retries:
retries_done += 1 retries_done += 1
logger.info(f"[{name}] 🔄 Retrying ({retries_done}/{self.policy.max_retries}) in {current_delay}s due to '{last_error}'...")
sleep_delay = current_delay
if self.policy.jitter > 0:
sleep_delay += random.uniform(-self.policy.jitter, self.policy.jitter)
logger.info(
"[%s] Retrying (%s/%s) in %ss due to '%s'...",
name,
retries_done,
self.policy.max_retries,
current_delay,
last_error,
)
await asyncio.sleep(current_delay) await asyncio.sleep(current_delay)
try: try:
result = await target.action(*context.args, **context.kwargs) result = await target.action(*context.args, **context.kwargs)
context.result = result context.result = result
context.exception = None context.exception = None
logger.info(f"[{name}] ✅ Retry succeeded on attempt {retries_done}.") logger.info("[%s] Retry succeeded on attempt %s.", name, retries_done)
return return None
except Exception as retry_error: except Exception as retry_error:
last_error = retry_error last_error = retry_error
current_delay *= self.policy.backoff current_delay *= self.policy.backoff
logger.warning(f"[{name}] ⚠️ Retry attempt {retries_done}/{self.policy.max_retries} failed due to '{retry_error}'.") logger.warning(
"[%s] Retry attempt %s/%s failed due to '%s'.",
name,
retries_done,
self.policy.max_retries,
retry_error,
)
context.exception = last_error context.exception = last_error
logger.error(f"[{name}] ❌ All {self.policy.max_retries} retries failed.") logger.error("[%s] All %s retries failed.", name, self.policy.max_retries)
return

19
falyx/retry_utils.py Normal file
View File

@ -0,0 +1,19 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""retry_utils.py"""
from falyx.action.action import Action
from falyx.action.base import BaseAction
from falyx.hook_manager import HookType
from falyx.retry import RetryHandler, RetryPolicy
def enable_retries_recursively(action: BaseAction, policy: RetryPolicy | None):
if not policy:
policy = RetryPolicy(enabled=True)
if isinstance(action, Action):
action.retry_policy = policy
action.retry_policy.enabled = True
action.hooks.register(HookType.ON_ERROR, RetryHandler(policy).retry_on_error)
if hasattr(action, "actions"):
for sub in action.actions:
enable_retries_recursively(sub, policy)

442
falyx/selection.py Normal file
View File

@ -0,0 +1,442 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""selection.py"""
from dataclasses import dataclass
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.themes import OneColors
from falyx.utils import CaseInsensitiveDict, chunks
from falyx.validators import int_range_validator, key_validator
@dataclass
class SelectionOption:
"""Represents a single selection option with a description and a value."""
description: str
value: Any
style: str = OneColors.WHITE
def __post_init__(self):
if not isinstance(self.description, str):
raise TypeError("SelectionOption description must be a string.")
def render(self, key: str) -> str:
"""Render the selection option for display."""
key = escape(f"[{key}]")
return f"[{OneColors.WHITE}]{key}[/] [{self.style}]{self.description}[/]"
class SelectionOptionMap(CaseInsensitiveDict):
"""
Manages selection options including validation and reserved key protection.
"""
RESERVED_KEYS: set[str] = set()
def __init__(
self,
options: dict[str, SelectionOption] | None = None,
allow_reserved: bool = False,
):
super().__init__()
self.allow_reserved = allow_reserved
if options:
self.update(options)
def _add_reserved(self, key: str, option: SelectionOption) -> None:
"""Add a reserved key, bypassing validation."""
norm_key = key.upper()
super().__setitem__(norm_key, option)
def __setitem__(self, key: str, option: SelectionOption) -> None:
if not isinstance(option, SelectionOption):
raise TypeError(f"Value for key '{key}' must be a SelectionOption.")
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 SelectionOptionMap."
)
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 update(self, other=None, **kwargs):
"""Update the selection options with another dictionary."""
if other:
for key, option in other.items():
if not isinstance(option, SelectionOption):
raise TypeError(f"Value for key '{key}' must be a SelectionOption.")
self[key] = option
for key, option in kwargs.items():
if not isinstance(option, SelectionOption):
raise TypeError(f"Value for key '{key}' must be a SelectionOption.")
self[key] = option
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
def render_table_base(
title: str,
*,
caption: str = "",
columns: int = 4,
box_style: box.Box = box.SIMPLE,
show_lines: bool = False,
show_header: bool = False,
show_footer: bool = False,
style: str = "",
header_style: str = "",
footer_style: str = "",
title_style: str = "",
caption_style: str = "",
highlight: bool = True,
column_names: Sequence[str] | None = None,
) -> Table:
table = Table(
title=title,
caption=caption,
box=box_style,
show_lines=show_lines,
show_header=show_header,
show_footer=show_footer,
style=style,
header_style=header_style,
footer_style=footer_style,
title_style=title_style,
caption_style=caption_style,
highlight=highlight,
)
if column_names:
for column_name in column_names:
table.add_column(column_name)
else:
for _ in range(columns):
table.add_column()
return table
def render_selection_grid(
title: str,
selections: Sequence[str],
*,
columns: int = 4,
caption: str = "",
box_style: box.Box = box.SIMPLE,
show_lines: bool = False,
show_header: bool = False,
show_footer: bool = False,
style: str = "",
header_style: str = "",
footer_style: str = "",
title_style: str = "",
caption_style: str = "",
highlight: bool = False,
) -> Table:
"""Create a selection table with the given parameters."""
table = render_table_base(
title=title,
caption=caption,
columns=columns,
box_style=box_style,
show_lines=show_lines,
show_header=show_header,
show_footer=show_footer,
style=style,
header_style=header_style,
footer_style=footer_style,
title_style=title_style,
caption_style=caption_style,
highlight=highlight,
)
for chunk in chunks(selections, columns):
table.add_row(*chunk)
return table
def render_selection_indexed_table(
title: str,
selections: Sequence[str],
*,
columns: int = 4,
caption: str = "",
box_style: box.Box = box.SIMPLE,
show_lines: bool = False,
show_header: bool = False,
show_footer: bool = False,
style: str = "",
header_style: str = "",
footer_style: str = "",
title_style: str = "",
caption_style: str = "",
highlight: bool = False,
formatter: Callable[[int, str], str] | None = None,
) -> Table:
"""Create a selection table with the given parameters."""
table = render_table_base(
title=title,
caption=caption,
columns=columns,
box_style=box_style,
show_lines=show_lines,
show_header=show_header,
show_footer=show_footer,
style=style,
header_style=header_style,
footer_style=footer_style,
title_style=title_style,
caption_style=caption_style,
highlight=highlight,
)
for indexes, chunk in zip(
chunks(range(len(selections)), columns), chunks(selections, columns)
):
row = [
formatter(index, selection) if formatter else f"[{index}] {selection}"
for index, selection in zip(indexes, chunk)
]
table.add_row(*row)
return table
def render_selection_dict_table(
title: str,
selections: dict[str, SelectionOption],
*,
columns: int = 2,
caption: str = "",
box_style: box.Box = box.SIMPLE,
show_lines: bool = False,
show_header: bool = False,
show_footer: bool = False,
style: str = "",
header_style: str = "",
footer_style: str = "",
title_style: str = "",
caption_style: str = "",
highlight: bool = False,
) -> Table:
"""Create a selection table with the given parameters."""
table = render_table_base(
title=title,
caption=caption,
columns=columns,
box_style=box_style,
show_lines=show_lines,
show_header=show_header,
show_footer=show_footer,
style=style,
header_style=header_style,
footer_style=footer_style,
title_style=title_style,
caption_style=caption_style,
highlight=highlight,
)
for chunk in chunks(selections.items(), columns):
row = []
for key, option in chunk:
row.append(
f"[{OneColors.WHITE}][{key.upper()}] "
f"[{option.style}]{option.description}[/]"
)
table.add_row(*row)
return table
async def prompt_for_index(
max_index: int,
table: Table,
*,
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,
) -> int:
prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="truecolor")
if show_table:
console.print(table, justify="center")
selection = await prompt_session.prompt_async(
message=prompt_message,
validator=int_range_validator(min_index, max_index),
default=default_selection,
)
return int(selection)
async def prompt_for_selection(
keys: Sequence[str] | KeysView[str],
table: Table,
*,
default_selection: str = "",
console: Console | None = None,
prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ",
show_table: bool = True,
) -> 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")
selected = await prompt_session.prompt_async(
message=prompt_message,
validator=key_validator(keys),
default=default_selection,
)
return selected
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 = "",
columns: int = 4,
caption: str = "",
box_style: box.Box = box.SIMPLE,
show_lines: bool = False,
show_header: bool = False,
show_footer: bool = False,
style: str = "",
header_style: str = "",
footer_style: str = "",
title_style: str = "",
caption_style: str = "",
highlight: bool = False,
):
"""Prompt for a selection. Return the selected item."""
table = render_selection_indexed_table(
title=title,
selections=selections,
columns=columns,
caption=caption,
box_style=box_style,
show_lines=show_lines,
show_header=show_header,
show_footer=show_footer,
style=style,
header_style=header_style,
footer_style=footer_style,
title_style=title_style,
caption_style=caption_style,
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,
)
return selections[selection_index]
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 = "",
) -> Any:
"""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")
return await prompt_for_selection(
selections.keys(),
table,
default_selection=default_selection,
console=console,
prompt_session=prompt_session,
prompt_message=prompt_message,
)
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 = "",
) -> 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")
selection_key = await prompt_for_selection(
selections.keys(),
table,
default_selection=default_selection,
console=console,
prompt_session=prompt_session,
prompt_message=prompt_message,
)
return selections[selection_key].value
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 = "",
):
"""Prompt for a key from a dict, but return the value."""
table = render_selection_dict_table(
title,
selections,
)
return await select_value_from_dict(
selections=selections,
table=table,
console=console,
prompt_session=prompt_session,
prompt_message=prompt_message,
default_selection=default_selection,
)

38
falyx/signals.py Normal file
View File

@ -0,0 +1,38 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""signals.py"""
class FlowSignal(BaseException):
"""Base class for all flow control signals in Falyx.
These are not errors. They're used to control flow like quitting,
going back, or restarting from user input or nested menus.
"""
class QuitSignal(FlowSignal):
"""Raised to signal an immediate exit from the CLI framework."""
def __init__(self, message: str = "Quit signal received."):
super().__init__(message)
class BackSignal(FlowSignal):
"""Raised to return control to the previous menu or caller."""
def __init__(self, message: str = "Back signal received."):
super().__init__(message)
class CancelSignal(FlowSignal):
"""Raised to cancel the current command or action."""
def __init__(self, message: str = "Cancel signal received."):
super().__init__(message)
class HelpSignal(FlowSignal):
"""Raised to display help information."""
def __init__(self, message: str = "Help signal received."):
super().__init__(message)

33
falyx/tagged_table.py Normal file
View File

@ -0,0 +1,33 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""tagged_table.py"""
from collections import defaultdict
from rich import box
from rich.table import Table
from falyx.command import Command
from falyx.falyx import Falyx
def build_tagged_table(flx: Falyx) -> Table:
"""Custom table builder that groups commands by tags."""
table = Table(title=flx.title, show_header=False, box=box.SIMPLE) # type: ignore[arg-type]
# Group commands by first tag
grouped: dict[str, list[Command]] = defaultdict(list)
for cmd in flx.commands.values():
first_tag = cmd.tags[0] if cmd.tags else "Other"
grouped[first_tag.capitalize()].append(cmd)
# Add grouped commands to table
for group_name, commands in grouped.items():
table.add_row(f"[bold underline]{group_name} Commands[/]")
for cmd in commands:
table.add_row(f"[{cmd.key}] [{cmd.style}]{cmd.description}")
table.add_row("")
# Add bottom row
for row in flx.get_bottom_row():
table.add_row(row)
return table

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

@ -17,6 +17,7 @@ Example dynamic usage:
console.print("Hello!", style=NordColors.NORD12bu) console.print("Hello!", style=NordColors.NORD12bu)
# => Renders "Hello!" in #D08770 (Nord12) plus bold and underline styles # => Renders "Hello!" in #D08770 (Nord12) plus bold and underline styles
""" """
import re import re
from difflib import get_close_matches from difflib import get_close_matches
@ -82,14 +83,17 @@ class ColorsMeta(type):
except AttributeError: except AttributeError:
error_msg = [f"'{cls.__name__}' has no color named '{base}'."] error_msg = [f"'{cls.__name__}' has no color named '{base}'."]
valid_bases = [ valid_bases = [
key for key, val in cls.__dict__.items() if isinstance(val, str) and key
not key.startswith("__") for key, val in cls.__dict__.items()
if isinstance(val, str) and not key.startswith("__")
] ]
suggestions = get_close_matches(base, valid_bases, n=1, cutoff=0.5) suggestions = get_close_matches(base, valid_bases, n=1, cutoff=0.5)
if suggestions: if suggestions:
error_msg.append(f"Did you mean '{suggestions[0]}'?") error_msg.append(f"Did you mean '{suggestions[0]}'?")
if valid_bases: if valid_bases:
error_msg.append(f"Valid base color names include: {', '.join(valid_bases)}") error_msg.append(
f"Valid base color names include: {', '.join(valid_bases)}"
)
raise AttributeError(" ".join(error_msg)) from None raise AttributeError(" ".join(error_msg)) from None
if not isinstance(color_value, str): if not isinstance(color_value, str):
@ -105,7 +109,9 @@ class ColorsMeta(type):
if mapped_style: if mapped_style:
styles.append(mapped_style) styles.append(mapped_style)
else: else:
raise AttributeError(f"Unknown style flag '{letter}' in attribute '{name}'") raise AttributeError(
f"Unknown style flag '{letter}' in attribute '{name}'"
)
order = {"b": 1, "i": 2, "u": 3, "d": 4, "r": 5, "s": 6} order = {"b": 1, "i": 2, "u": 3, "d": 4, "r": 5, "s": 6}
styles_sorted = sorted(styles, key=lambda s: order[s[0]]) styles_sorted = sorted(styles, key=lambda s: order[s[0]])
@ -133,7 +139,6 @@ class OneColors(metaclass=ColorsMeta):
BLUE = "#61AFEF" BLUE = "#61AFEF"
MAGENTA = "#C678DD" MAGENTA = "#C678DD"
@classmethod @classmethod
def as_dict(cls): def as_dict(cls):
""" """
@ -143,10 +148,10 @@ class OneColors(metaclass=ColorsMeta):
return { return {
attr: getattr(cls, attr) attr: getattr(cls, attr)
for attr in dir(cls) for attr in dir(cls)
if not callable(getattr(cls, attr)) and if not callable(getattr(cls, attr)) and not attr.startswith("__")
not attr.startswith("__")
} }
class NordColors(metaclass=ColorsMeta): class NordColors(metaclass=ColorsMeta):
""" """
Defines the Nord color palette as class attributes. Defines the Nord color palette as class attributes.
@ -215,19 +220,19 @@ class NordColors(metaclass=ColorsMeta):
return { return {
attr: getattr(cls, attr) attr: getattr(cls, attr)
for attr in dir(cls) for attr in dir(cls)
if attr.startswith("NORD") and if attr.startswith("NORD") and not callable(getattr(cls, attr))
not callable(getattr(cls, attr))
} }
@classmethod @classmethod
def aliases(cls): def aliases(cls):
""" """
Returns a dictionary of *all* other aliases Returns a dictionary of *all* other aliases
(Polar Night, Snow Storm, Frost, Aurora). (Polar Night, Snow Storm, Frost, Aurora).
""" """
skip_prefixes = ("NORD", "__") skip_prefixes = ("NORD", "__")
alias_names = [ alias_names = [
attr for attr in dir(cls) attr
for attr in dir(cls)
if not any(attr.startswith(sp) for sp in skip_prefixes) if not any(attr.startswith(sp) for sp in skip_prefixes)
and not callable(getattr(cls, attr)) and not callable(getattr(cls, attr))
] ]
@ -264,7 +269,6 @@ NORD_THEME_STYLES: dict[str, Style] = {
"blink2": Style(blink2=True), "blink2": Style(blink2=True),
"reverse": Style(reverse=True), "reverse": Style(reverse=True),
"strike": Style(strike=True), "strike": Style(strike=True),
# --------------------------------------------------------------- # ---------------------------------------------------------------
# Basic color names mapped to Nord # Basic color names mapped to Nord
# --------------------------------------------------------------- # ---------------------------------------------------------------
@ -277,7 +281,6 @@ NORD_THEME_STYLES: dict[str, Style] = {
"cyan": Style(color=NordColors.CYAN), "cyan": Style(color=NordColors.CYAN),
"blue": Style(color=NordColors.BLUE), "blue": Style(color=NordColors.BLUE),
"white": Style(color=NordColors.SNOW_STORM_BRIGHTEST), "white": Style(color=NordColors.SNOW_STORM_BRIGHTEST),
# --------------------------------------------------------------- # ---------------------------------------------------------------
# Inspect # Inspect
# --------------------------------------------------------------- # ---------------------------------------------------------------
@ -292,14 +295,12 @@ NORD_THEME_STYLES: dict[str, Style] = {
"inspect.help": Style(color=NordColors.FROST_ICE), "inspect.help": Style(color=NordColors.FROST_ICE),
"inspect.doc": Style(dim=True), "inspect.doc": Style(dim=True),
"inspect.value.border": Style(color=NordColors.GREEN), "inspect.value.border": Style(color=NordColors.GREEN),
# --------------------------------------------------------------- # ---------------------------------------------------------------
# Live / Layout # Live / Layout
# --------------------------------------------------------------- # ---------------------------------------------------------------
"live.ellipsis": Style(bold=True, color=NordColors.RED), "live.ellipsis": Style(bold=True, color=NordColors.RED),
"layout.tree.row": Style(dim=False, color=NordColors.RED), "layout.tree.row": Style(dim=False, color=NordColors.RED),
"layout.tree.column": Style(dim=False, color=NordColors.FROST_DEEP), "layout.tree.column": Style(dim=False, color=NordColors.FROST_DEEP),
# --------------------------------------------------------------- # ---------------------------------------------------------------
# Logging # Logging
# --------------------------------------------------------------- # ---------------------------------------------------------------
@ -314,7 +315,6 @@ NORD_THEME_STYLES: dict[str, Style] = {
"log.time": Style(color=NordColors.FROST_ICE, dim=True), "log.time": Style(color=NordColors.FROST_ICE, dim=True),
"log.message": Style.null(), "log.message": Style.null(),
"log.path": Style(dim=True), "log.path": Style(dim=True),
# --------------------------------------------------------------- # ---------------------------------------------------------------
# Python repr # Python repr
# --------------------------------------------------------------- # ---------------------------------------------------------------
@ -340,18 +340,18 @@ NORD_THEME_STYLES: dict[str, Style] = {
"repr.bool_true": Style(color=NordColors.GREEN, italic=True), "repr.bool_true": Style(color=NordColors.GREEN, italic=True),
"repr.bool_false": Style(color=NordColors.RED, italic=True), "repr.bool_false": Style(color=NordColors.RED, italic=True),
"repr.none": Style(color=NordColors.PURPLE, italic=True), "repr.none": Style(color=NordColors.PURPLE, italic=True),
"repr.url": Style(underline=True, color=NordColors.FROST_ICE, italic=False, bold=False), "repr.url": Style(
underline=True, color=NordColors.FROST_ICE, italic=False, bold=False
),
"repr.uuid": Style(color=NordColors.YELLOW, bold=False), "repr.uuid": Style(color=NordColors.YELLOW, bold=False),
"repr.call": Style(color=NordColors.PURPLE, bold=True), "repr.call": Style(color=NordColors.PURPLE, bold=True),
"repr.path": Style(color=NordColors.PURPLE), "repr.path": Style(color=NordColors.PURPLE),
"repr.filename": Style(color=NordColors.PURPLE), "repr.filename": Style(color=NordColors.PURPLE),
# --------------------------------------------------------------- # ---------------------------------------------------------------
# Rule # Rule
# --------------------------------------------------------------- # ---------------------------------------------------------------
"rule.line": Style(color=NordColors.GREEN), "rule.line": Style(color=NordColors.GREEN),
"rule.text": Style.null(), "rule.text": Style.null(),
# --------------------------------------------------------------- # ---------------------------------------------------------------
# JSON # JSON
# --------------------------------------------------------------- # ---------------------------------------------------------------
@ -362,7 +362,6 @@ NORD_THEME_STYLES: dict[str, Style] = {
"json.number": Style(color=NordColors.FROST_ICE, bold=True, italic=False), "json.number": Style(color=NordColors.FROST_ICE, bold=True, italic=False),
"json.str": Style(color=NordColors.GREEN, italic=False, bold=False), "json.str": Style(color=NordColors.GREEN, italic=False, bold=False),
"json.key": Style(color=NordColors.FROST_ICE, bold=True), "json.key": Style(color=NordColors.FROST_ICE, bold=True),
# --------------------------------------------------------------- # ---------------------------------------------------------------
# Prompt # Prompt
# --------------------------------------------------------------- # ---------------------------------------------------------------
@ -371,12 +370,10 @@ NORD_THEME_STYLES: dict[str, Style] = {
"prompt.default": Style(color=NordColors.FROST_ICE, bold=True), "prompt.default": Style(color=NordColors.FROST_ICE, bold=True),
"prompt.invalid": Style(color=NordColors.RED), "prompt.invalid": Style(color=NordColors.RED),
"prompt.invalid.choice": Style(color=NordColors.RED), "prompt.invalid.choice": Style(color=NordColors.RED),
# --------------------------------------------------------------- # ---------------------------------------------------------------
# Pretty # Pretty
# --------------------------------------------------------------- # ---------------------------------------------------------------
"pretty": Style.null(), "pretty": Style.null(),
# --------------------------------------------------------------- # ---------------------------------------------------------------
# Scope # Scope
# --------------------------------------------------------------- # ---------------------------------------------------------------
@ -384,7 +381,6 @@ NORD_THEME_STYLES: dict[str, Style] = {
"scope.key": Style(color=NordColors.YELLOW, italic=True), "scope.key": Style(color=NordColors.YELLOW, italic=True),
"scope.key.special": Style(color=NordColors.YELLOW, italic=True, dim=True), "scope.key.special": Style(color=NordColors.YELLOW, italic=True, dim=True),
"scope.equals": Style(color=NordColors.RED), "scope.equals": Style(color=NordColors.RED),
# --------------------------------------------------------------- # ---------------------------------------------------------------
# Table # Table
# --------------------------------------------------------------- # ---------------------------------------------------------------
@ -393,7 +389,6 @@ NORD_THEME_STYLES: dict[str, Style] = {
"table.cell": Style.null(), "table.cell": Style.null(),
"table.title": Style(italic=True), "table.title": Style(italic=True),
"table.caption": Style(italic=True, dim=True), "table.caption": Style(italic=True, dim=True),
# --------------------------------------------------------------- # ---------------------------------------------------------------
# Traceback # Traceback
# --------------------------------------------------------------- # ---------------------------------------------------------------
@ -405,7 +400,6 @@ NORD_THEME_STYLES: dict[str, Style] = {
"traceback.exc_type": Style(color=NordColors.RED, bold=True), "traceback.exc_type": Style(color=NordColors.RED, bold=True),
"traceback.exc_value": Style.null(), "traceback.exc_value": Style.null(),
"traceback.offset": Style(color=NordColors.RED, bold=True), "traceback.offset": Style(color=NordColors.RED, bold=True),
# --------------------------------------------------------------- # ---------------------------------------------------------------
# Progress bars # Progress bars
# --------------------------------------------------------------- # ---------------------------------------------------------------
@ -423,13 +417,11 @@ NORD_THEME_STYLES: dict[str, Style] = {
"progress.data.speed": Style(color=NordColors.RED), "progress.data.speed": Style(color=NordColors.RED),
"progress.spinner": Style(color=NordColors.GREEN), "progress.spinner": Style(color=NordColors.GREEN),
"status.spinner": Style(color=NordColors.GREEN), "status.spinner": Style(color=NordColors.GREEN),
# --------------------------------------------------------------- # ---------------------------------------------------------------
# Tree # Tree
# --------------------------------------------------------------- # ---------------------------------------------------------------
"tree": Style(), "tree": Style(),
"tree.line": Style(), "tree.line": Style(),
# --------------------------------------------------------------- # ---------------------------------------------------------------
# Markdown # Markdown
# --------------------------------------------------------------- # ---------------------------------------------------------------
@ -438,8 +430,12 @@ NORD_THEME_STYLES: dict[str, Style] = {
"markdown.em": Style(italic=True), "markdown.em": Style(italic=True),
"markdown.emph": Style(italic=True), # For commonmark compatibility "markdown.emph": Style(italic=True), # For commonmark compatibility
"markdown.strong": Style(bold=True), "markdown.strong": Style(bold=True),
"markdown.code": Style(bold=True, color=NordColors.FROST_ICE, bgcolor=NordColors.POLAR_NIGHT_ORIGIN), "markdown.code": Style(
"markdown.code_block": Style(color=NordColors.FROST_ICE, bgcolor=NordColors.POLAR_NIGHT_ORIGIN), bold=True, color=NordColors.FROST_ICE, bgcolor=NordColors.POLAR_NIGHT_ORIGIN
),
"markdown.code_block": Style(
color=NordColors.FROST_ICE, bgcolor=NordColors.POLAR_NIGHT_ORIGIN
),
"markdown.block_quote": Style(color=NordColors.PURPLE), "markdown.block_quote": Style(color=NordColors.PURPLE),
"markdown.list": Style(color=NordColors.FROST_ICE), "markdown.list": Style(color=NordColors.FROST_ICE),
"markdown.item": Style(), "markdown.item": Style(),
@ -457,7 +453,6 @@ NORD_THEME_STYLES: dict[str, Style] = {
"markdown.link": Style(color=NordColors.FROST_ICE), "markdown.link": Style(color=NordColors.FROST_ICE),
"markdown.link_url": Style(color=NordColors.FROST_SKY, underline=True), "markdown.link_url": Style(color=NordColors.FROST_SKY, underline=True),
"markdown.s": Style(strike=True), "markdown.s": Style(strike=True),
# --------------------------------------------------------------- # ---------------------------------------------------------------
# ISO8601 # ISO8601
# --------------------------------------------------------------- # ---------------------------------------------------------------
@ -504,7 +499,9 @@ if __name__ == "__main__":
console.print(f"Caught error: {error}", style="red") console.print(f"Caught error: {error}", style="red")
# Demonstrate a traceback style: # Demonstrate a traceback style:
console.print("\n8) Raising and displaying a traceback with Nord styling:\n", style="bold") console.print(
"\n8) Raising and displaying a traceback with Nord styling:\n", style="bold"
)
try: try:
raise ValueError("Nord test exception!") raise ValueError("Nord test exception!")
except ValueError: except ValueError:

View File

@ -1,37 +1,54 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""utils.py""" """utils.py"""
from __future__ import annotations
import functools import functools
import inspect import inspect
import logging import logging
import os import os
import shutil
import sys
from itertools import islice from itertools import islice
from typing import Any, Awaitable, Callable, TypeVar from typing import Any, Awaitable, Callable, TypeVar
import pythonjsonlogger.json import pythonjsonlogger.json
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import (AnyFormattedText, FormattedText,
merge_formatted_text)
from rich.logging import RichHandler from rich.logging import RichHandler
from falyx.themes.colors import OneColors
logger = logging.getLogger("falyx")
T = TypeVar("T") T = TypeVar("T")
async def _noop(*args, **kwargs):
async def _noop(*_, **__):
pass pass
def get_program_invocation() -> str:
"""Returns the recommended program invocation prefix."""
script = sys.argv[0]
program = shutil.which(script)
if program:
return os.path.basename(program)
executable = sys.executable
if "python" in executable:
return f"python {script}"
return script
def is_coroutine(function: Callable[..., Any]) -> bool: def is_coroutine(function: Callable[..., Any]) -> bool:
return inspect.iscoroutinefunction(function) return inspect.iscoroutinefunction(function)
def ensure_async(function: Callable[..., T]) -> Callable[..., Awaitable[T]]: def ensure_async(function: Callable[..., T]) -> Callable[..., Awaitable[T]]:
if is_coroutine(function): if is_coroutine(function):
return function # type: ignore return function # type: ignore
@functools.wraps(function) @functools.wraps(function)
async def async_wrapper(*args, **kwargs) -> T: async def async_wrapper(*args, **kwargs) -> T:
return function(*args, **kwargs) return function(*args, **kwargs)
if not callable(function):
raise TypeError(f"{function} is not callable")
return async_wrapper return async_wrapper
@ -45,41 +62,33 @@ def chunks(iterator, size):
yield chunk yield chunk
async def async_confirm(message: AnyFormattedText = "Are you sure?") -> bool:
session: PromptSession = PromptSession()
while True:
merged_message: AnyFormattedText = merge_formatted_text([message, FormattedText([(OneColors.LIGHT_YELLOW_b, " [Y/n] ")])])
answer: str = (await session.prompt_async(merged_message)).strip().lower()
if answer in ("y", "yes"):
return True
if answer in ("n", "no", ""):
return False
print("Please enter y or n.")
class CaseInsensitiveDict(dict): class CaseInsensitiveDict(dict):
"""A case-insensitive dictionary that treats all keys as uppercase.""" """A case-insensitive dictionary that treats all keys as uppercase."""
def _normalize_key(self, key):
return key.upper() if isinstance(key, str) else key
def __setitem__(self, key, value): def __setitem__(self, key, value):
super().__setitem__(key.upper(), value) super().__setitem__(self._normalize_key(key), value)
def __getitem__(self, key): def __getitem__(self, key):
return super().__getitem__(key.upper()) return super().__getitem__(self._normalize_key(key))
def __contains__(self, key): def __contains__(self, key):
return super().__contains__(key.upper()) return super().__contains__(self._normalize_key(key))
def get(self, key, default=None): def get(self, key, default=None):
return super().get(key.upper(), default) return super().get(self._normalize_key(key), default)
def pop(self, key, default=None): def pop(self, key, default=None):
return super().pop(key.upper(), default) return super().pop(self._normalize_key(key), default)
def update(self, other=None, **kwargs): def update(self, other=None, **kwargs):
items = {}
if other: if other:
other = {k.upper(): v for k, v in other.items()} items.update({self._normalize_key(k): v for k, v in other.items()})
kwargs = {k.upper(): v for k, v in kwargs.items()} items.update({self._normalize_key(k): v for k, v in kwargs.items()})
super().update(other, **kwargs) super().update(items)
def running_in_container() -> bool: def running_in_container() -> bool:
@ -104,11 +113,13 @@ def setup_logging(
console_log_level: int = logging.WARNING, console_log_level: int = logging.WARNING,
): ):
""" """
Configure logging for Falyx with support for both CLI-friendly and structured JSON output. Configure logging for Falyx with support for both CLI-friendly and structured
JSON output.
This function sets up separate logging handlers for console and file output, with optional This function sets up separate logging handlers for console and file output,
support for JSON formatting. It also auto-detects whether the application is running inside with optional support for JSON formatting. It also auto-detects whether the
a container to default to machine-readable logs when appropriate. application is running inside a container to default to machine-readable logs
when appropriate.
Args: Args:
mode (str | None): mode (str | None):
@ -131,7 +142,8 @@ def setup_logging(
- Clears existing root handlers before setup. - Clears existing root handlers before setup.
- Configures console logging using either Rich (for CLI) or JSON formatting. - Configures console logging using either Rich (for CLI) or JSON formatting.
- Configures file logging in plain text or JSON based on `json_log_to_file`. - Configures file logging in plain text or JSON based on `json_log_to_file`.
- Automatically sets logging levels for noisy third-party modules (`urllib3`, `asyncio`). - Automatically sets logging levels for noisy third-party modules
(`urllib3`, `asyncio`, `markdown_it`).
- Propagates logs from the "falyx" logger to ensure centralized output. - Propagates logs from the "falyx" logger to ensure centralized output.
Raises: Raises:
@ -162,7 +174,9 @@ def setup_logging(
elif mode == "json": elif mode == "json":
console_handler = logging.StreamHandler() console_handler = logging.StreamHandler()
console_handler.setFormatter( console_handler.setFormatter(
pythonjsonlogger.json.JsonFormatter("%(asctime)s %(name)s %(levelname)s %(message)s") pythonjsonlogger.json.JsonFormatter(
"%(asctime)s %(name)s %(levelname)s %(message)s"
)
) )
else: else:
raise ValueError(f"Invalid log mode: {mode}") raise ValueError(f"Invalid log mode: {mode}")
@ -170,17 +184,21 @@ def setup_logging(
console_handler.setLevel(console_log_level) console_handler.setLevel(console_log_level)
root.addHandler(console_handler) root.addHandler(console_handler)
file_handler = logging.FileHandler(log_filename) file_handler = logging.FileHandler(log_filename, "a", "UTF-8")
file_handler.setLevel(file_log_level) file_handler.setLevel(file_log_level)
if json_log_to_file: if json_log_to_file:
file_handler.setFormatter( file_handler.setFormatter(
pythonjsonlogger.json.JsonFormatter("%(asctime)s %(name)s %(levelname)s %(message)s") pythonjsonlogger.json.JsonFormatter(
"%(asctime)s %(name)s %(levelname)s %(message)s"
)
) )
else: else:
file_handler.setFormatter(logging.Formatter( file_handler.setFormatter(
"%(asctime)s [%(name)s] [%(levelname)s] %(message)s", logging.Formatter(
datefmt="%Y-%m-%d %H:%M:%S" "%(asctime)s [%(name)s] [%(levelname)s] %(message)s",
)) datefmt="%Y-%m-%d %H:%M:%S",
)
)
root.addHandler(file_handler) root.addHandler(file_handler)
logging.getLogger("urllib3").setLevel(logging.WARNING) logging.getLogger("urllib3").setLevel(logging.WARNING)

47
falyx/validators.py Normal file
View File

@ -0,0 +1,47 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""validators.py"""
from typing import KeysView, Sequence
from prompt_toolkit.validation import Validator
def int_range_validator(minimum: int, maximum: int) -> Validator:
"""Validator for integer ranges."""
def validate(text: str) -> bool:
try:
value = int(text)
if not minimum <= value <= maximum:
return False
return True
except ValueError:
return False
return Validator.from_callable(
validate,
error_message=f"Invalid input. Enter a number between {minimum} and {maximum}.",
)
def key_validator(keys: Sequence[str] | KeysView[str]) -> Validator:
"""Validator for key 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. Available keys: {', '.join(keys)}."
)
def yes_no_validator() -> Validator:
"""Validator for yes/no inputs."""
def validate(text: str) -> bool:
if text.upper() not in ["Y", "N"]:
return False
return True
return Validator.from_callable(validate, error_message="Enter 'Y' or 'n'.")

View File

@ -1 +1 @@
__version__ = "0.1.5" __version__ = "0.1.50"

View File

@ -146,7 +146,10 @@ disable=abstract-method,
wrong-import-order, wrong-import-order,
xrange-builtin, xrange-builtin,
zip-builtin-not-iterating, zip-builtin-not-iterating,
broad-exception-caught broad-exception-caught,
too-many-positional-arguments,
inconsistent-quotes,
import-outside-toplevel
[REPORTS] [REPORTS]
@ -260,7 +263,7 @@ generated-members=
[FORMAT] [FORMAT]
# Maximum number of characters on a single line. # Maximum number of characters on a single line.
max-line-length=80 max-line-length=90
# TODO(https://github.com/PyCQA/pylint/issues/3352): Direct pylint to exempt # TODO(https://github.com/PyCQA/pylint/issues/3352): Direct pylint to exempt
# lines made too long by directives to pytype. # lines made too long by directives to pytype.

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "falyx" name = "falyx"
version = "0.1.5" version = "0.1.50"
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"
@ -13,15 +13,23 @@ prompt_toolkit = "^3.0"
rich = "^13.0" rich = "^13.0"
pydantic = "^2.0" pydantic = "^2.0"
python-json-logger = "^3.3.0" python-json-logger = "^3.3.0"
toml = "^0.10"
pyyaml = "^6.0"
aiohttp = "^3.11"
python-dateutil = "^2.8"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
pytest = "^7.0" pytest = "^8.3.5"
pytest-asyncio = "^0.20" pytest-asyncio = "^0.20"
ruff = "^0.3" ruff = "^0.3"
toml = "^0.10"
black = { version = "^25.0", allow-prereleases = true }
mypy = { version = "^1.0", allow-prereleases = true }
isort = { version = "^5.0", allow-prereleases = true }
pytest-cov = "^4.0"
[tool.poetry.scripts] [tool.poetry.scripts]
falyx = "falyx.cli.main:main" falyx = "falyx.__main__:main"
sync-version = "scripts.sync_version:main"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]
@ -30,7 +38,7 @@ build-backend = "poetry.core.masonry.api"
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
asyncio_mode = "auto" asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function" #asyncio_default_fixture_loop_scope = "function"
[tool.pylint."MESSAGES CONTROL"] [tool.pylint."MESSAGES CONTROL"]
disable = ["broad-exception-caught"] disable = ["broad-exception-caught"]

View File

@ -1,8 +1,10 @@
"""scripts/sync_version.py""" """scripts/sync_version.py"""
import toml
from pathlib import Path from pathlib import Path
import toml
def main(): def main():
pyproject_path = Path(__file__).parent.parent / "pyproject.toml" pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
version_path = Path(__file__).parent.parent / "falyx" / "version.py" version_path = Path(__file__).parent.parent / "falyx" / "version.py"
@ -13,5 +15,6 @@ def main():
version_path.write_text(f'__version__ = "{version}"\n') version_path.write_text(f'__version__ = "{version}"\n')
print(f"✅ Synced version: {version}{version_path}") print(f"✅ Synced version: {version}{version_path}")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

223
tests/test_action_basic.py Normal file
View File

@ -0,0 +1,223 @@
import pytest
from falyx.action import Action, ChainedAction, FallbackAction, LiteralInputAction
from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er
asyncio_default_fixture_loop_scope = "function"
# --- Helpers ---
async def capturing_hook(context: ExecutionContext):
context.extra["hook_triggered"] = True
# --- Fixtures ---
@pytest.fixture(autouse=True)
def clean_registry():
er.clear()
yield
er.clear()
@pytest.mark.asyncio
async def test_action_callable():
"""Test if Action can be created with a callable."""
action = Action("test_action", lambda: "Hello, World!")
result = await action()
assert result == "Hello, World!"
@pytest.mark.asyncio
async def test_action_async_callable():
"""Test if Action can be created with an async callable."""
async def async_callable():
return "Hello, World!"
action = Action("test_action", async_callable)
result = await action()
assert result == "Hello, World!"
assert (
str(action)
== "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False)"
)
assert (
repr(action)
== "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False)"
)
@pytest.mark.asyncio
async def test_chained_action():
"""Test if ChainedAction can be created and used."""
action1 = Action("one", lambda: 1)
action2 = Action("two", lambda: 2)
chain = ChainedAction(
name="Simple Chain",
actions=[action1, action2],
return_list=True,
)
result = await chain()
assert result == [1, 2]
assert (
str(chain)
== "ChainedAction(name='Simple Chain', actions=['one', 'two'], auto_inject=False, return_list=True)"
)
@pytest.mark.asyncio
async def test_action_group():
"""Test if ActionGroup can be created and used."""
action1 = Action("one", lambda: 1)
action2 = Action("two", lambda: 2)
group = ChainedAction(
name="Simple Group",
actions=[action1, action2],
return_list=True,
)
result = await group()
assert result == [1, 2]
assert (
str(group)
== "ChainedAction(name='Simple Group', actions=['one', 'two'], auto_inject=False, return_list=True)"
)
@pytest.mark.asyncio
async def test_action_non_callable():
"""Test if Action raises an error when created with a non-callable."""
with pytest.raises(TypeError):
Action("test_action", 42)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"return_list, expected",
[
(True, [1, 2, 3]),
(False, 3),
],
)
async def test_chained_action_return_modes(return_list, expected):
chain = ChainedAction(
name="Simple Chain",
actions=[
Action(name="one", action=lambda: 1),
Action(name="two", action=lambda: 2),
Action(name="three", action=lambda: 3),
],
return_list=return_list,
)
result = await chain()
assert result == expected
@pytest.mark.asyncio
@pytest.mark.parametrize(
"return_list, auto_inject, expected",
[
(True, True, [1, 2, 3]),
(True, False, [1, 2, 3]),
(False, True, 3),
(False, False, 3),
],
)
async def test_chained_action_literals(return_list, auto_inject, expected):
chain = ChainedAction(
name="Literal Chain",
actions=[1, 2, 3],
return_list=return_list,
auto_inject=auto_inject,
)
result = await chain()
assert result == expected
@pytest.mark.asyncio
async def test_literal_input_action():
"""Test if LiteralInputAction can be created and used."""
action = LiteralInputAction("Hello, World!")
result = await action()
assert result == "Hello, World!"
assert action.value == "Hello, World!"
assert str(action) == "LiteralInputAction(value='Hello, World!')"
@pytest.mark.asyncio
async def test_fallback_action():
"""Test if FallbackAction can be created and used."""
action = FallbackAction("Fallback value")
chain = ChainedAction(
name="Fallback Chain",
actions=[
Action(name="one", action=lambda: None),
action,
],
)
result = await chain()
assert result == "Fallback value"
assert str(action) == "FallbackAction(fallback='Fallback value')"
@pytest.mark.asyncio
async def test_remove_action_from_chain():
"""Test if an action can be removed from a chain."""
action1 = Action(name="one", action=lambda: 1)
action2 = Action(name="two", action=lambda: 2)
chain = ChainedAction(
name="Simple Chain",
actions=[action1, action2],
)
assert len(chain.actions) == 2
# Remove the first action
chain.remove_action(action1.name)
assert len(chain.actions) == 1
assert chain.actions[0] == action2
@pytest.mark.asyncio
async def test_has_action_in_chain():
"""Test if an action can be checked for presence in a chain."""
action1 = Action(name="one", action=lambda: 1)
action2 = Action(name="two", action=lambda: 2)
chain = ChainedAction(
name="Simple Chain",
actions=[action1, action2],
)
assert chain.has_action(action1.name) is True
assert chain.has_action(action2.name) is True
# Remove the first action
chain.remove_action(action1.name)
assert chain.has_action(action1.name) is False
assert chain.has_action(action2.name) is True
@pytest.mark.asyncio
async def test_get_action_from_chain():
"""Test if an action can be retrieved from a chain."""
action1 = Action(name="one", action=lambda: 1)
action2 = Action(name="two", action=lambda: 2)
chain = ChainedAction(
name="Simple Chain",
actions=[action1, action2],
)
assert chain.get_action(action1.name) == action1
assert chain.get_action(action2.name) == action2
# Remove the first action
chain.remove_action(action1.name)
assert chain.get_action(action1.name) is None
assert chain.get_action(action2.name) == action2

View File

View File

View File

@ -0,0 +1,46 @@
import pickle
import warnings
import pytest
from falyx.action import ProcessAction
from falyx.execution_registry import ExecutionRegistry as er
# --- Fixtures ---
@pytest.fixture(autouse=True)
def clean_registry():
er.clear()
yield
er.clear()
def slow_add(x, y):
return x + y
# --- Tests ---
@pytest.mark.asyncio
async def test_process_action_executes_correctly():
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
action = ProcessAction(name="proc", action=slow_add, args=(2, 3))
result = await action()
assert result == 5
unpickleable = lambda x: x + 1 # noqa: E731
@pytest.mark.asyncio
async def test_process_action_rejects_unpickleable():
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
action = ProcessAction(name="proc_fail", action=unpickleable, args=(2,))
with pytest.raises(pickle.PicklingError, match="Can't pickle"):
await action()

View File

@ -0,0 +1,36 @@
import pytest
from falyx.action import Action, ChainedAction
from falyx.execution_registry import ExecutionRegistry as er
from falyx.retry_utils import enable_retries_recursively
asyncio_default_fixture_loop_scope = "function"
# --- Fixtures ---
@pytest.fixture(autouse=True)
def clean_registry():
er.clear()
yield
er.clear()
def test_action_enable_retry():
"""Test if Action can be created with retry=True."""
action = Action("test_action", lambda: "Hello, World!", retry=True)
assert action.retry_policy.enabled is True
@pytest.mark.asyncio
async def test_enable_retries_recursively():
"""Test if Action can be created with retry=True."""
action = Action("test_action", lambda: "Hello, World!")
assert action.retry_policy.enabled is False
chained_action = ChainedAction(
name="Chained Action",
actions=[action],
)
enable_retries_recursively(chained_action, policy=None)
assert action.retry_policy.enabled is True

View File

@ -1,54 +1,52 @@
import pytest import pytest
import asyncio
import pickle from falyx.action import Action, ActionGroup, ChainedAction, FallbackAction
import warnings from falyx.context import ExecutionContext
from falyx.action import Action, ChainedAction, ActionGroup, ProcessAction
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.context import ExecutionContext, ResultsContext
asyncio_default_fixture_loop_scope = "function" asyncio_default_fixture_loop_scope = "function"
# --- Helpers --- # --- Helpers ---
async def dummy_action(x: int = 0) -> int:
return x + 1
async def capturing_hook(context: ExecutionContext): async def capturing_hook(context: ExecutionContext):
context.extra["hook_triggered"] = True context.extra["hook_triggered"] = True
# --- Fixtures --- # --- Fixtures ---
@pytest.fixture
def sample_action():
return Action(name="increment", action=dummy_action, kwargs={"x": 5})
@pytest.fixture @pytest.fixture
def hook_manager(): def hook_manager():
hm = HookManager() hm = HookManager()
hm.register(HookType.BEFORE, capturing_hook) hm.register(HookType.BEFORE, capturing_hook)
return hm return hm
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def clean_registry(): def clean_registry():
er.clear() er.clear()
yield yield
er.clear() er.clear()
# --- Tests --- # --- Tests ---
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_action_runs_correctly(sample_action): async def test_action_runs_correctly():
async def dummy_action(x: int = 0) -> int:
return x + 1
sample_action = Action(name="increment", action=dummy_action, kwargs={"x": 5})
result = await sample_action() result = await sample_action()
assert result == 6 assert result == 6
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_action_hook_lifecycle(hook_manager): async def test_action_hook_lifecycle(hook_manager):
action = Action( async def a1():
name="hooked", return 42
action=lambda: 42,
hooks=hook_manager action = Action(name="hooked", action=a1, hooks=hook_manager)
)
await action() await action()
@ -56,67 +54,124 @@ async def test_action_hook_lifecycle(hook_manager):
assert context.name == "hooked" assert context.name == "hooked"
assert context.extra.get("hook_triggered") is True assert context.extra.get("hook_triggered") is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_chained_action_with_result_injection(): async def test_chained_action_with_result_injection():
async def a1():
return 1
async def a2(last_result):
return last_result + 5
async def a3(last_result):
return last_result * 2
actions = [ actions = [
Action(name="start", action=lambda: 1), Action(name="start", action=a1),
Action(name="add_last", action=lambda last_result: last_result + 5, inject_last_result=True), Action(name="add_last", action=a2, inject_last_result=True),
Action(name="multiply", action=lambda last_result: last_result * 2, inject_last_result=True) Action(name="multiply", action=a3, inject_last_result=True),
] ]
chain = ChainedAction(name="test_chain", actions=actions, inject_last_result=True) chain = ChainedAction(
name="test_chain", actions=actions, inject_last_result=True, return_list=True
)
result = await chain() result = await chain()
assert result == [1, 6, 12] assert result == [1, 6, 12]
chain = ChainedAction(name="test_chain", actions=actions, inject_last_result=True)
result = await chain()
assert result == 12
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_action_group_runs_in_parallel(): async def test_action_group_runs_in_parallel():
async def a1():
return 1
async def a2():
return 2
async def a3():
return 3
actions = [ actions = [
Action(name="a", action=lambda: 1), Action(name="a", action=a1),
Action(name="b", action=lambda: 2), Action(name="b", action=a2),
Action(name="c", action=lambda: 3), Action(name="c", action=a3),
] ]
group = ActionGroup(name="parallel", actions=actions) group = ActionGroup(name="parallel", actions=actions)
result = await group() result = await group()
result_dict = dict(result) result_dict = dict(result)
assert result_dict == {"a": 1, "b": 2, "c": 3} assert result_dict == {"a": 1, "b": 2, "c": 3}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_chained_action_inject_from_action(): async def test_chained_action_inject_from_action():
async def a1(last_result):
return last_result + 10
async def a2(last_result):
return last_result + 5
inner_chain = ChainedAction( inner_chain = ChainedAction(
name="inner_chain", name="inner_chain",
actions=[ actions=[
Action(name="inner_first", action=lambda last_result: last_result + 10, inject_last_result=True), Action(name="inner_first", action=a1, inject_last_result=True),
Action(name="inner_second", action=lambda last_result: last_result + 5, inject_last_result=True), Action(name="inner_second", action=a2, inject_last_result=True),
] ],
return_list=True,
) )
actions = [
Action(name="first", action=lambda: 1),
Action(name="second", action=lambda last_result: last_result + 2, inject_last_result=True),
inner_chain,
async def a3():
return 1
async def a4(last_result):
return last_result + 2
actions = [
Action(name="first", action=a3),
Action(name="second", action=a4, inject_last_result=True),
inner_chain,
] ]
outer_chain = ChainedAction(name="test_chain", actions=actions) outer_chain = ChainedAction(name="test_chain", actions=actions, return_list=True)
result = await outer_chain() result = await outer_chain()
assert result == [1, 3, [13, 18]] assert result == [1, 3, [13, 18]]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_chained_action_with_group(): async def test_chained_action_with_group():
async def a1(last_result):
return last_result + 1
async def a2(last_result):
return last_result + 2
async def a3():
return 3
group = ActionGroup( group = ActionGroup(
name="group", name="group",
actions=[ actions=[
Action(name="a", action=lambda last_result: last_result + 1, inject_last_result=True), Action(name="a", action=a1, inject_last_result=True),
Action(name="b", action=lambda last_result: last_result + 2, inject_last_result=True), Action(name="b", action=a2, inject_last_result=True),
Action(name="c", action=lambda: 3), Action(name="c", action=a3),
] ],
) )
async def a4():
return 1
async def a5(last_result):
return last_result + 2
actions = [ actions = [
Action(name="first", action=lambda: 1), Action(name="first", action=a4),
Action(name="second", action=lambda last_result: last_result + 2, inject_last_result=True), Action(name="second", action=a5, inject_last_result=True),
group, group,
] ]
chain = ChainedAction(name="test_chain", actions=actions) chain = ChainedAction(name="test_chain", actions=actions, return_list=True)
result = await chain() result = await chain()
assert result == [1, 3, [("a", 4), ("b", 5), ("c", 3)]] assert result == [1, 3, [("a", 4), ("b", 5), ("c", 3)]]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_action_error_triggers_error_hook(): async def test_action_error_triggers_error_hook():
def fail(): def fail():
@ -136,6 +191,7 @@ async def test_action_error_triggers_error_hook():
assert flag.get("called") is True assert flag.get("called") is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_chained_action_rollback_on_failure(): async def test_chained_action_rollback_on_failure():
rollback_called = [] rollback_called = []
@ -151,7 +207,7 @@ async def test_chained_action_rollback_on_failure():
actions = [ actions = [
Action(name="ok", action=success, rollback=rollback_fn), Action(name="ok", action=success, rollback=rollback_fn),
Action(name="fail", action=fail, rollback=rollback_fn) Action(name="fail", action=fail, rollback=rollback_fn),
] ]
chain = ChainedAction(name="chain", actions=actions) chain = ChainedAction(name="chain", actions=actions)
@ -161,37 +217,25 @@ async def test_chained_action_rollback_on_failure():
assert rollback_called == ["rolled back"] assert rollback_called == ["rolled back"]
def slow_add(x, y):
return x + y
@pytest.mark.asyncio
async def test_process_action_executes_correctly():
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
action = ProcessAction(name="proc", func=slow_add, args=(2, 3))
result = await action()
assert result == 5
unpickleable = lambda x: x + 1
@pytest.mark.asyncio
async def test_process_action_rejects_unpickleable():
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
action = ProcessAction(name="proc_fail", func=unpickleable, args=(2,))
with pytest.raises(pickle.PicklingError, match="Can't pickle"):
await action()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_register_hooks_recursively_propagates(): async def test_register_hooks_recursively_propagates():
hook = lambda ctx: ctx.extra.update({"test_marker": True}) def hook(context):
context.extra.update({"test_marker": True})
chain = ChainedAction(name="chain", actions=[ async def a1():
Action(name="a", action=lambda: 1), return 1
Action(name="b", action=lambda: 2),
]) async def a2():
return 2
chain = ChainedAction(
name="chain",
actions=[
Action(name="a", action=a1),
Action(name="b", action=a2),
],
)
chain.register_hooks_recursively(HookType.BEFORE, hook) chain.register_hooks_recursively(HookType.BEFORE, hook)
await chain() await chain()
@ -199,6 +243,7 @@ async def test_register_hooks_recursively_propagates():
for ctx in er.get_by_name("a") + er.get_by_name("b"): for ctx in er.get_by_name("a") + er.get_by_name("b"):
assert ctx.extra.get("test_marker") is True assert ctx.extra.get("test_marker") is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_action_hook_recovers_error(): async def test_action_hook_recovers_error():
async def flaky(): async def flaky():
@ -215,16 +260,329 @@ async def test_action_hook_recovers_error():
result = await action() result = await action()
assert result == 99 assert result == 99
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_action_group_injects_last_result(): async def test_action_group_injects_last_result():
group = ActionGroup(name="group", actions=[ async def a1(last_result):
Action(name="g1", action=lambda last_result: last_result + 10, inject_last_result=True), return last_result + 10
Action(name="g2", action=lambda last_result: last_result + 20, inject_last_result=True),
]) async def a2(last_result):
chain = ChainedAction(name="with_group", actions=[ return last_result + 20
Action(name="first", action=lambda: 5),
group, group = ActionGroup(
]) name="group",
actions=[
Action(name="g1", action=a1, inject_last_result=True),
Action(name="g2", action=a2, inject_last_result=True),
],
)
async def a3():
return 5
chain = ChainedAction(
name="with_group",
actions=[
Action(name="first", action=a3),
group,
],
return_list=True,
)
result = await chain() result = await chain()
result_dict = dict(result[1]) result_dict = dict(result[1])
assert result_dict == {"g1": 15, "g2": 25} assert result_dict == {"g1": 15, "g2": 25}
@pytest.mark.asyncio
async def test_action_inject_last_result():
async def a1():
return 1
async def a2(last_result):
return last_result + 1
a1 = Action(name="a1", action=a1)
a2 = Action(name="a2", action=a2, inject_last_result=True)
chain = ChainedAction(name="chain", actions=[a1, a2])
result = await chain()
assert result == 2
@pytest.mark.asyncio
async def test_action_inject_last_result_fail():
async def a1():
return 1
async def a2(last_result):
return last_result + 1
a1 = Action(name="a1", action=a1)
a2 = Action(name="a2", action=a2)
chain = ChainedAction(name="chain", actions=[a1, a2])
with pytest.raises(TypeError) as exc_info:
await chain()
assert "last_result" in str(exc_info.value)
@pytest.mark.asyncio
async def test_chained_action_auto_inject():
async def a1():
return 1
async def a2(last_result):
return last_result + 2
a1 = Action(name="a1", action=a1)
a2 = Action(name="a2", action=a2)
chain = ChainedAction(
name="chain", actions=[a1, a2], auto_inject=True, return_list=True
)
result = await chain()
assert result == [1, 3] # a2 receives last_result=1
@pytest.mark.asyncio
async def test_chained_action_no_auto_inject():
async def a1():
return 1
async def a2():
return 2
a1 = Action(name="a1", action=a1)
a2 = Action(name="a2", action=a2)
chain = ChainedAction(
name="no_inject", actions=[a1, a2], auto_inject=False, return_list=True
)
result = await chain()
assert result == [1, 2] # a2 does not receive 1
@pytest.mark.asyncio
async def test_chained_action_auto_inject_after_first():
async def a1():
return 1
async def a2(last_result):
return last_result + 1
a1 = Action(name="a1", action=a1)
a2 = Action(name="a2", action=a2)
chain = ChainedAction(name="auto_inject", actions=[a1, a2], auto_inject=True)
result = await chain()
assert result == 2 # a2 receives last_result=1
@pytest.mark.asyncio
async def test_chained_action_with_literal_input():
async def a1(last_result):
return last_result + " world"
a1 = Action(name="a1", action=a1)
chain = ChainedAction(name="literal_inject", actions=["hello", a1], auto_inject=True)
result = await chain()
assert result == "hello world" # "hello" is injected as last_result
@pytest.mark.asyncio
async def test_chained_action_manual_inject_override():
async def a1():
return 10
async def a2(last_result):
return last_result * 2
a1 = Action(name="a1", action=a1)
a2 = Action(name="a2", action=a2, inject_last_result=True)
chain = ChainedAction(name="manual_override", actions=[a1, a2], auto_inject=False)
result = await chain()
assert result == 20 # Even without auto_inject, a2 still gets last_result
@pytest.mark.asyncio
async def test_chained_action_with_mid_literal():
async def fetch_data():
# Imagine this is some dynamic API call
return None # Simulate failure or missing data
async def validate_data(last_result):
if last_result is None:
raise ValueError("Missing data!")
return last_result
async def enrich_data(last_result):
return f"Enriched: {last_result}"
chain = ChainedAction(
name="fallback_pipeline",
actions=[
Action(name="FetchData", action=fetch_data),
"default_value", # <-- literal fallback injected mid-chain
Action(name="ValidateData", action=validate_data),
Action(name="EnrichData", action=enrich_data),
],
auto_inject=True,
return_list=True,
)
result = await chain()
assert result == [None, "default_value", "default_value", "Enriched: default_value"]
@pytest.mark.asyncio
async def test_chained_action_with_mid_fallback():
async def fetch_data():
# Imagine this is some dynamic API call
return None # Simulate failure or missing data
async def validate_data(last_result):
if last_result is None:
raise ValueError("Missing data!")
return last_result
async def enrich_data(last_result):
return f"Enriched: {last_result}"
chain = ChainedAction(
name="fallback_pipeline",
actions=[
Action(name="FetchData", action=fetch_data),
FallbackAction(fallback="default_value"),
Action(name="ValidateData", action=validate_data),
Action(name="EnrichData", action=enrich_data),
],
auto_inject=True,
return_list=True,
)
result = await chain()
assert result == [None, "default_value", "default_value", "Enriched: default_value"]
@pytest.mark.asyncio
async def test_chained_action_with_success_mid_fallback():
async def fetch_data():
# Imagine this is some dynamic API call
return "Result" # Simulate success
async def validate_data(last_result):
if last_result is None:
raise ValueError("Missing data!")
return last_result
async def enrich_data(last_result):
return f"Enriched: {last_result}"
chain = ChainedAction(
name="fallback_pipeline",
actions=[
Action(name="FetchData", action=fetch_data),
FallbackAction(fallback="default_value"),
Action(name="ValidateData", action=validate_data),
Action(name="EnrichData", action=enrich_data),
],
auto_inject=True,
return_list=True,
)
result = await chain()
assert result == ["Result", "Result", "Result", "Enriched: Result"]
@pytest.mark.asyncio
async def test_action_group_partial_failure():
async def succeed():
return "ok"
async def fail():
raise ValueError("oops")
group = ActionGroup(
name="partial_group",
actions=[
Action(name="succeed_action", action=succeed),
Action(name="fail_action", action=fail),
],
)
with pytest.raises(Exception) as exc_info:
await group()
assert er.get_by_name("succeed_action")[0].result == "ok"
assert er.get_by_name("fail_action")[0].exception is not None
assert "fail_action" in str(exc_info.value)
@pytest.mark.asyncio
async def test_chained_action_with_nested_group():
async def g1(last_result):
return last_result + "10"
async def g2(last_result):
return last_result + "20"
group = ActionGroup(
name="nested_group",
actions=[
Action(name="g1", action=g1, inject_last_result=True),
Action(name="g2", action=g2, inject_last_result=True),
],
)
chain = ChainedAction(
name="chain_with_group",
actions=[
"start",
group,
],
auto_inject=True,
return_list=True,
)
result = await chain()
# "start" -> group both receive "start" as last_result
assert result[0] == "start"
assert dict(result[1]) == {
"g1": "start10",
"g2": "start20",
} # Assuming string concatenation for example
@pytest.mark.asyncio
async def test_chained_action_double_fallback():
async def fetch_data(last_result=None):
raise ValueError("No data!") # Simulate failure
async def validate_data(last_result):
if last_result is None:
raise ValueError("No data!")
return last_result
async def enrich(last_result):
return f"Enriched: {last_result}"
chain = ChainedAction(
name="fallback_chain",
actions=[
Action(name="Fetch", action=fetch_data),
FallbackAction(fallback="default1"),
Action(name="Validate", action=validate_data),
Action(name="Fetch", action=fetch_data),
FallbackAction(fallback="default2"),
Action(name="Enrich", action=enrich),
],
auto_inject=True,
return_list=True,
)
result = await chain()
assert result == [
None,
"default1",
"default1",
None,
"default2",
"Enriched: default2",
]

View File

@ -0,0 +1,25 @@
import pytest
from falyx.action import Action, ActionFactoryAction, ChainedAction
def make_chain(value) -> ChainedAction:
return ChainedAction(
"test_chain",
[
Action("action1", lambda: value + "_1"),
Action("action2", lambda: value + "_2"),
],
return_list=True,
)
@pytest.mark.asyncio
async def test_action_factory_action():
action = ActionFactoryAction(
name="test_action", factory=make_chain, args=("test_value",)
)
result = await action()
assert result == ["test_value_1", "test_value_2"]

View File

@ -0,0 +1,28 @@
import pytest
from falyx.action import ChainedAction
from falyx.exceptions import EmptyChainError
@pytest.mark.asyncio
async def test_chained_action_raises_empty_chain_error_when_no_actions():
"""A ChainedAction with no actions should raise an EmptyChainError immediately."""
chain = ChainedAction(name="empty_chain", actions=[])
with pytest.raises(EmptyChainError) as exc_info:
await chain()
assert "No actions to execute." in str(exc_info.value)
assert "empty_chain" in str(exc_info.value)
@pytest.mark.asyncio
async def test_chained_action_raises_empty_chain_error_when_actions_are_none():
"""A ChainedAction with None as actions should raise an EmptyChainError immediately."""
chain = ChainedAction(name="none_chain", actions=None)
with pytest.raises(EmptyChainError) as exc_info:
await chain()
assert "No actions to execute." in str(exc_info.value)
assert "none_chain" in str(exc_info.value)

165
tests/test_command.py Normal file
View File

@ -0,0 +1,165 @@
# test_command.py
import pytest
from falyx.action import Action, BaseIOAction, ChainedAction
from falyx.command import Command
from falyx.execution_registry import ExecutionRegistry as er
from falyx.retry import RetryPolicy
asyncio_default_fixture_loop_scope = "function"
# --- Fixtures ---
@pytest.fixture(autouse=True)
def clean_registry():
er.clear()
yield
er.clear()
# --- Dummy Action ---
async def dummy_action():
return "ok"
# --- Dummy IO Action ---
class DummyInputAction(BaseIOAction):
async def _run(self, *args, **kwargs):
return "needs input"
async def preview(self, parent=None):
pass
# --- Tests ---
@pytest.mark.asyncio
async def test_command_creation():
"""Test if Command can be created with a callable."""
action = Action("test_action", dummy_action)
cmd = Command(key="TEST", description="Test Command", action=action)
assert cmd.key == "TEST"
assert cmd.description == "Test Command"
assert cmd.action == action
result = await cmd()
assert result == "ok"
assert cmd.result == "ok"
def test_command_str():
"""Test if Command string representation is correct."""
action = Action("test_action", dummy_action)
cmd = Command(key="TEST", description="Test Command", action=action)
assert (
str(cmd)
== "Command(key='TEST', description='Test Command' action='Action(name='test_action', action=dummy_action, args=(), kwargs={}, retry=False)')"
)
def test_enable_retry():
"""Command should enable retry if action is an Action and retry is set to True."""
cmd = Command(
key="A",
description="Retry action",
action=Action(
name="retry_action",
action=lambda: 42,
),
retry=True,
)
assert cmd.retry is True
assert cmd.action.retry_policy.enabled is True
def test_enable_retry_with_retry_policy():
"""Command should enable retry if action is an Action and retry_policy is set."""
retry_policy = RetryPolicy(
max_retries=3,
delay=1,
backoff=2,
enabled=True,
)
cmd = Command(
key="B",
description="Retry action with policy",
action=Action(
name="retry_action_with_policy",
action=lambda: 42,
),
retry_policy=retry_policy,
)
assert cmd.action.retry_policy.enabled is True
assert cmd.action.retry_policy == retry_policy
def test_enable_retry_not_action():
"""Command should not enable retry if action is not an Action."""
cmd = Command(
key="C",
description="Retry action",
action=DummyInputAction,
retry=True,
)
assert cmd.retry is True
with pytest.raises(Exception) as exc_info:
assert cmd.action.retry_policy.enabled is False
assert "'function' object has no attribute 'retry_policy'" in str(exc_info.value)
def test_chain_retry_all():
"""retry_all should retry all Actions inside a ChainedAction recursively."""
chain = ChainedAction(
name="ChainWithRetry",
actions=[
Action(name="action1", action=lambda: 1),
Action(name="action2", action=lambda: 2),
],
)
cmd = Command(
key="D",
description="Chain with retry",
action=chain,
retry_all=True,
)
assert cmd.retry_all is True
assert cmd.retry_policy.enabled is True
assert chain.actions[0].retry_policy.enabled is True
assert chain.actions[1].retry_policy.enabled is True
def test_chain_retry_all_not_base_action():
"""retry_all should not be set if action is not a ChainedAction."""
cmd = Command(
key="E",
description="Chain with retry",
action=DummyInputAction,
retry_all=True,
)
assert cmd.retry_all is True
with pytest.raises(Exception) as exc_info:
assert cmd.action.retry_policy.enabled is False
assert "'function' object has no attribute 'retry_policy'" in str(exc_info.value)
@pytest.mark.asyncio
async def test_command_exception_handling():
"""Test if Command handles exceptions correctly."""
async def bad_action():
raise ZeroDivisionError("This is a test exception")
cmd = Command(key="TEST", description="Test Command", action=bad_action)
with pytest.raises(ZeroDivisionError):
await cmd()
assert cmd.result is None
assert isinstance(cmd._context.exception, ZeroDivisionError)
def test_command_bad_action():
"""Test if Command raises an exception when action is not callable."""
with pytest.raises(TypeError) as exc_info:
Command(key="TEST", description="Test Command", action="not_callable")
assert str(exc_info.value) == "Action must be a callable or an instance of BaseAction"

View File

@ -0,0 +1,828 @@
import pytest
from falyx.exceptions import CommandArgumentError
from falyx.parsers import ArgumentAction, CommandArgumentParser
from falyx.signals import HelpSignal
async def build_parser_and_parse(args, config):
cap = CommandArgumentParser()
config(cap)
return await cap.parse_args(args)
@pytest.mark.asyncio
async def test_none():
def config(parser):
parser.add_argument("--foo", type=str)
parsed = await build_parser_and_parse(None, config)
assert parsed["foo"] is None
@pytest.mark.asyncio
async def test_append_multiple_flags():
def config(parser):
parser.add_argument("--tag", action=ArgumentAction.APPEND, type=str)
parsed = await build_parser_and_parse(
["--tag", "a", "--tag", "b", "--tag", "c"], config
)
assert parsed["tag"] == ["a", "b", "c"]
@pytest.mark.asyncio
async def test_positional_nargs_plus_and_single():
def config(parser):
parser.add_argument("files", nargs="+", type=str)
parser.add_argument("mode", nargs=1)
parsed = await build_parser_and_parse(["a", "b", "c", "prod"], config)
assert parsed["files"] == ["a", "b", "c"]
assert parsed["mode"] == "prod"
@pytest.mark.asyncio
async def test_type_validation_failure():
def config(parser):
parser.add_argument("--count", type=int)
with pytest.raises(CommandArgumentError):
await build_parser_and_parse(["--count", "abc"], config)
@pytest.mark.asyncio
async def test_required_field_missing():
def config(parser):
parser.add_argument("--env", type=str, required=True)
with pytest.raises(CommandArgumentError):
await build_parser_and_parse([], config)
@pytest.mark.asyncio
async def test_choices_enforced():
def config(parser):
parser.add_argument("--mode", choices=["dev", "prod"])
with pytest.raises(CommandArgumentError):
await build_parser_and_parse(["--mode", "staging"], config)
@pytest.mark.asyncio
async def test_boolean_flags():
def config(parser):
parser.add_argument("--debug", action=ArgumentAction.STORE_TRUE)
parser.add_argument("--no-debug", action=ArgumentAction.STORE_FALSE)
parsed = await build_parser_and_parse(["--debug", "--no-debug"], config)
assert parsed["debug"] is True
assert parsed["no_debug"] is False
parsed = await build_parser_and_parse([], config)
assert parsed["debug"] is False
assert parsed["no_debug"] is True
@pytest.mark.asyncio
async def test_count_action():
def config(parser):
parser.add_argument("-v", action=ArgumentAction.COUNT)
parsed = await build_parser_and_parse(["-v", "-v", "-v"], config)
assert parsed["v"] == 3
@pytest.mark.asyncio
async def test_nargs_star():
def config(parser):
parser.add_argument("args", nargs="*", type=str)
parsed = await build_parser_and_parse(["one", "two", "three"], config)
assert parsed["args"] == ["one", "two", "three"]
@pytest.mark.asyncio
async def test_flag_and_positional_mix():
def config(parser):
parser.add_argument("--env", type=str)
parser.add_argument("tasks", nargs="+")
parsed = await build_parser_and_parse(["--env", "prod", "build", "test"], config)
assert parsed["env"] == "prod"
assert parsed["tasks"] == ["build", "test"]
def test_duplicate_dest_fails():
parser = CommandArgumentParser()
parser.add_argument("--foo", dest="shared")
with pytest.raises(CommandArgumentError):
parser.add_argument("bar", dest="shared")
def test_add_argument_positional_flag_conflict():
parser = CommandArgumentParser()
# ✅ Single positional argument should work
parser.add_argument("faylx")
# ❌ Multiple positional flags is invalid
with pytest.raises(CommandArgumentError):
parser.add_argument("falyx", "test")
def test_add_argument_positional_and_flag_conflict():
parser = CommandArgumentParser()
# ❌ Cannot mix positional and optional in one declaration
with pytest.raises(CommandArgumentError):
parser.add_argument("faylx", "--falyx")
def test_add_argument_multiple_optional_flags_same_dest():
parser = CommandArgumentParser()
# ✅ Valid: multiple flags for same dest
parser.add_argument("-f", "--falyx")
arg = parser._arguments[-1]
assert arg.dest == "falyx"
assert arg.flags == ("-f", "--falyx")
def test_add_argument_flag_dest_conflict():
parser = CommandArgumentParser()
# First one is fine
parser.add_argument("falyx")
# ❌ Cannot reuse dest name with another flag or positional
with pytest.raises(CommandArgumentError):
parser.add_argument("--test", dest="falyx")
def test_add_argument_flag_and_positional_conflict_dest_inference():
parser = CommandArgumentParser()
# ❌ "--falyx" and "falyx" result in dest conflict
parser.add_argument("--falyx")
with pytest.raises(CommandArgumentError):
parser.add_argument("falyx")
def test_add_argument_multiple_flags_custom_dest():
parser = CommandArgumentParser()
# ✅ Multiple flags with explicit dest
parser.add_argument("-f", "--falyx", "--test", dest="falyx")
arg = parser._arguments[-1]
assert arg.dest == "falyx"
assert arg.flags == ("-f", "--falyx", "--test")
def test_add_argument_multiple_flags_dest():
parser = CommandArgumentParser()
# ✅ Multiple flags with implicit dest first non -flag
parser.add_argument("-f", "--falyx", "--test")
arg = parser._arguments[-1]
assert arg.dest == "falyx"
assert arg.flags == ("-f", "--falyx", "--test")
def test_add_argument_single_flag_dest():
parser = CommandArgumentParser()
# ✅ Single flag with explicit dest
parser.add_argument("-f")
arg = parser._arguments[-1]
assert arg.dest == "f"
assert arg.flags == ("-f",)
def test_add_argument_bad_dest():
parser = CommandArgumentParser()
# ❌ Invalid dest name
with pytest.raises(CommandArgumentError):
parser.add_argument("--falyx", dest="1falyx")
with pytest.raises(CommandArgumentError):
parser.add_argument("--falyx", dest="falyx%")
def test_add_argument_bad_flag():
parser = CommandArgumentParser()
# ❌ Invalid flag name
with pytest.raises(CommandArgumentError):
parser.add_argument("--1falyx")
with pytest.raises(CommandArgumentError):
parser.add_argument("--!falyx")
with pytest.raises(CommandArgumentError):
parser.add_argument("_")
with pytest.raises(CommandArgumentError):
parser.add_argument(None)
with pytest.raises(CommandArgumentError):
parser.add_argument(0)
with pytest.raises(CommandArgumentError):
parser.add_argument("-")
with pytest.raises(CommandArgumentError):
parser.add_argument("--")
with pytest.raises(CommandArgumentError):
parser.add_argument("-asdf")
def test_add_argument_duplicate_flags():
parser = CommandArgumentParser()
parser.add_argument("--falyx")
# ❌ Duplicate flag
with pytest.raises(CommandArgumentError):
parser.add_argument("--test", "--falyx")
# ❌ Duplicate flag
with pytest.raises(CommandArgumentError):
parser.add_argument("falyx")
def test_add_argument_no_flags():
parser = CommandArgumentParser()
# ❌ No flags provided
with pytest.raises(CommandArgumentError):
parser.add_argument()
def test_add_argument_default_value():
parser = CommandArgumentParser()
# ✅ Default value provided
parser.add_argument("--falyx", default="default_value")
arg = parser._arguments[-1]
assert arg.dest == "falyx"
assert arg.flags == ("--falyx",)
assert arg.default == "default_value"
def test_add_argument_bad_default():
parser = CommandArgumentParser()
# ❌ Invalid default value
with pytest.raises(CommandArgumentError):
parser.add_argument("--falyx", type=int, default="1falyx")
def test_add_argument_bad_default_list():
parser = CommandArgumentParser()
# ❌ Invalid default value
with pytest.raises(CommandArgumentError):
parser.add_argument("--falyx", type=int, default=["a", 2, 3])
def test_add_argument_bad_action():
parser = CommandArgumentParser()
# ❌ Invalid action
with pytest.raises(CommandArgumentError):
parser.add_argument("--falyx", action="invalid_action")
# ❌ Invalid action type
with pytest.raises(CommandArgumentError):
parser.add_argument("--falyx", action=123)
def test_add_argument_default_not_in_choices():
parser = CommandArgumentParser()
# ❌ Default value not in choices
with pytest.raises(CommandArgumentError):
parser.add_argument("--falyx", choices=["a", "b"], default="c")
@pytest.mark.asyncio
async def test_add_argument_choices():
parser = CommandArgumentParser()
# ✅ Choices provided
parser.add_argument("--falyx", choices=["a", "b", "c"])
arg = parser._arguments[-1]
assert arg.dest == "falyx"
assert arg.flags == ("--falyx",)
assert arg.choices == ["a", "b", "c"]
args = await parser.parse_args(["--falyx", "a"])
assert args["falyx"] == "a"
with pytest.raises(CommandArgumentError):
await parser.parse_args(["--falyx", "d"])
def test_add_argument_choices_invalid():
parser = CommandArgumentParser()
# ❌ Invalid choices
with pytest.raises(CommandArgumentError):
parser.add_argument("--falyx", choices=["a", "b"], default="c")
with pytest.raises(CommandArgumentError):
parser.add_argument("--bad", choices=123)
with pytest.raises(CommandArgumentError):
parser.add_argument("--bad3", choices={1: "a", 2: "b"})
with pytest.raises(CommandArgumentError):
parser.add_argument("--bad4", choices=["a", "b"], type=int)
def test_add_argument_bad_nargs():
parser = CommandArgumentParser()
with pytest.raises(CommandArgumentError):
parser.add_argument("--falyx", nargs="invalid")
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", nargs="123")
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", nargs=[1, 2])
with pytest.raises(CommandArgumentError):
parser.add_argument("--too", action="count", nargs=5)
with pytest.raises(CommandArgumentError):
parser.add_argument("falyx", action="store_true", nargs=5)
def test_add_argument_nargs():
parser = CommandArgumentParser()
parser.add_argument("--falyx", nargs=2)
arg = parser._arguments[-1]
assert arg.dest == "falyx"
assert arg.flags == ("--falyx",)
assert arg.nargs == 2
def test_add_argument_valid_nargs():
# Valid nargs int, +, * and ?
parser = CommandArgumentParser()
parser.add_argument("--falyx", nargs="+")
arg = parser._arguments[-1]
assert arg.nargs == "+"
parser.add_argument("--test", nargs="*")
arg = parser._arguments[-1]
assert arg.nargs == "*"
parser.add_argument("--test2", nargs="?")
arg = parser._arguments[-1]
assert arg.nargs == "?"
def test_get_argument():
parser = CommandArgumentParser()
parser.add_argument("--falyx", type=str, default="default_value")
arg = parser.get_argument("falyx")
assert arg.dest == "falyx"
assert arg.flags == ("--falyx",)
assert arg.default == "default_value"
@pytest.mark.asyncio
async def test_parse_args_nargs():
parser = CommandArgumentParser()
parser.add_argument("files", nargs="+", type=str)
parser.add_argument("mode", nargs=1)
parser.add_argument("--action", action="store_true")
args = await parser.parse_args(["a", "b", "c", "--action"])
args = await parser.parse_args(["--action", "a", "b", "c"])
assert args["files"] == ["a", "b"]
assert args["mode"] == "c"
@pytest.mark.asyncio
async def test_parse_args_nargs_plus():
parser = CommandArgumentParser()
parser.add_argument("files", nargs="+", type=str)
args = await parser.parse_args(["a", "b", "c"])
assert args["files"] == ["a", "b", "c"]
args = await parser.parse_args(["a"])
assert args["files"] == ["a"]
@pytest.mark.asyncio
async def test_parse_args_flagged_nargs_plus():
parser = CommandArgumentParser()
parser.add_argument("--files", nargs="+", type=str)
args = await parser.parse_args(["--files", "a", "b", "c"])
assert args["files"] == ["a", "b", "c"]
args = await parser.parse_args(["--files", "a"])
print(args)
assert args["files"] == ["a"]
args = await parser.parse_args([])
assert args["files"] == []
@pytest.mark.asyncio
async def test_parse_args_numbered_nargs():
parser = CommandArgumentParser()
parser.add_argument("files", nargs=2, type=str)
args = await parser.parse_args(["a", "b"])
assert args["files"] == ["a", "b"]
with pytest.raises(CommandArgumentError):
args = await parser.parse_args(["a"])
print(args)
def test_parse_args_nargs_zero():
parser = CommandArgumentParser()
with pytest.raises(CommandArgumentError):
parser.add_argument("files", nargs=0, type=str)
@pytest.mark.asyncio
async def test_parse_args_nargs_more_than_expected():
parser = CommandArgumentParser()
parser.add_argument("files", nargs=2, type=str)
with pytest.raises(CommandArgumentError):
await parser.parse_args(["a", "b", "c", "d"])
@pytest.mark.asyncio
async def test_parse_args_nargs_one_or_none():
parser = CommandArgumentParser()
parser.add_argument("files", nargs="?", type=str)
args = await parser.parse_args(["a"])
assert args["files"] == "a"
args = await parser.parse_args([])
assert args["files"] is None
@pytest.mark.asyncio
async def test_parse_args_nargs_positional():
parser = CommandArgumentParser()
parser.add_argument("files", nargs="*", type=str)
args = await parser.parse_args(["a", "b", "c"])
assert args["files"] == ["a", "b", "c"]
args = await parser.parse_args([])
assert args["files"] == []
@pytest.mark.asyncio
async def test_parse_args_nargs_positional_plus():
parser = CommandArgumentParser()
parser.add_argument("files", nargs="+", type=str)
args = await parser.parse_args(["a", "b", "c"])
assert args["files"] == ["a", "b", "c"]
with pytest.raises(CommandArgumentError):
args = await parser.parse_args([])
@pytest.mark.asyncio
async def test_parse_args_nargs_multiple_positional():
parser = CommandArgumentParser()
parser.add_argument("files", nargs="+", type=str)
parser.add_argument("mode", nargs=1)
parser.add_argument("action", nargs="?")
parser.add_argument("target", nargs="*")
parser.add_argument("extra", nargs="+")
args = await parser.parse_args(["a", "b", "c", "d", "e"])
assert args["files"] == ["a", "b", "c"]
assert args["mode"] == "d"
assert args["action"] == []
assert args["target"] == []
assert args["extra"] == ["e"]
with pytest.raises(CommandArgumentError):
await parser.parse_args([])
@pytest.mark.asyncio
async def test_parse_args_nargs_none():
parser = CommandArgumentParser()
parser.add_argument("numbers", type=int)
parser.add_argument("mode")
await parser.parse_args(["1", "2"])
@pytest.mark.asyncio
async def test_parse_args_nargs_invalid_positional_arguments():
parser = CommandArgumentParser()
parser.add_argument("numbers", nargs="*", type=int)
parser.add_argument("mode", nargs=1)
with pytest.raises(CommandArgumentError):
await parser.parse_args(["1", "2", "c", "d"])
@pytest.mark.asyncio
async def test_parse_args_append():
parser = CommandArgumentParser()
parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int)
args = await parser.parse_args(["--numbers", "1", "--numbers", "2", "--numbers", "3"])
assert args["numbers"] == [1, 2, 3]
args = await parser.parse_args(["--numbers", "1"])
assert args["numbers"] == [1]
args = await parser.parse_args([])
assert args["numbers"] == []
@pytest.mark.asyncio
async def test_parse_args_nargs_int_append():
parser = CommandArgumentParser()
parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int, nargs=1)
args = await parser.parse_args(["--numbers", "1", "--numbers", "2", "--numbers", "3"])
assert args["numbers"] == [[1], [2], [3]]
args = await parser.parse_args(["--numbers", "1"])
assert args["numbers"] == [[1]]
args = await parser.parse_args([])
assert args["numbers"] == []
@pytest.mark.asyncio
async def test_parse_args_nargs_append():
parser = CommandArgumentParser()
parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int, nargs="*")
parser.add_argument("--mode")
args = await parser.parse_args(["1"])
assert args["numbers"] == [[1]]
args = await parser.parse_args(["1", "2", "3", "--mode", "numbers", "4", "5"])
assert args["numbers"] == [[1, 2, 3], [4, 5]]
assert args["mode"] == "numbers"
args = await parser.parse_args(["1", "2", "3"])
assert args["numbers"] == [[1, 2, 3]]
args = await parser.parse_args([])
assert args["numbers"] == []
@pytest.mark.asyncio
async def test_parse_args_int_optional_append():
parser = CommandArgumentParser()
parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int)
args = await parser.parse_args(["1"])
assert args["numbers"] == [1]
@pytest.mark.asyncio
async def test_parse_args_int_optional_append_multiple_values():
parser = CommandArgumentParser()
parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int)
with pytest.raises(CommandArgumentError):
await parser.parse_args(["1", "2"])
@pytest.mark.asyncio
async def test_parse_args_nargs_int_positional_append():
parser = CommandArgumentParser()
parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int, nargs=1)
args = await parser.parse_args(["1"])
assert args["numbers"] == [[1]]
with pytest.raises(CommandArgumentError):
await parser.parse_args(["1", "2", "3"])
parser2 = CommandArgumentParser()
parser2.add_argument("numbers", action=ArgumentAction.APPEND, type=int, nargs=2)
args = await parser2.parse_args(["1", "2"])
assert args["numbers"] == [[1, 2]]
with pytest.raises(CommandArgumentError):
await parser2.parse_args(["1", "2", "3"])
@pytest.mark.asyncio
async def test_parse_args_append_flagged_invalid_type():
parser = CommandArgumentParser()
parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int)
with pytest.raises(CommandArgumentError):
await parser.parse_args(["--numbers", "a"])
@pytest.mark.asyncio
async def test_append_groups_nargs():
cap = CommandArgumentParser()
cap.add_argument("--item", action=ArgumentAction.APPEND, type=str, nargs=2)
parsed = await cap.parse_args(["--item", "a", "b", "--item", "c", "d"])
assert parsed["item"] == [["a", "b"], ["c", "d"]]
with pytest.raises(CommandArgumentError):
await cap.parse_args(["--item", "a", "b", "--item", "c"])
@pytest.mark.asyncio
async def test_extend_flattened():
cap = CommandArgumentParser()
cap.add_argument("--value", action=ArgumentAction.EXTEND, type=str)
parsed = await cap.parse_args(["--value", "x", "--value", "y"])
assert parsed["value"] == ["x", "y"]
@pytest.mark.asyncio
async def test_parse_args_split_order():
cap = CommandArgumentParser()
cap.add_argument("a")
cap.add_argument("--x")
cap.add_argument("b", nargs="*")
args, kwargs = await cap.parse_args_split(["1", "--x", "100", "2"])
assert args == ("1", ["2"])
assert kwargs == {"x": "100"}
@pytest.mark.asyncio
async def test_help_signal_triggers():
parser = CommandArgumentParser()
parser.add_argument("--foo")
with pytest.raises(HelpSignal):
await parser.parse_args(["--help"])
@pytest.mark.asyncio
async def test_empty_parser_defaults():
parser = CommandArgumentParser()
with pytest.raises(HelpSignal):
await parser.parse_args(["--help"])
@pytest.mark.asyncio
async def test_extend_basic():
parser = CommandArgumentParser()
parser.add_argument("--tag", action=ArgumentAction.EXTEND, type=str)
args = await parser.parse_args(["--tag", "a", "--tag", "b", "--tag", "c"])
assert args["tag"] == ["a", "b", "c"]
@pytest.mark.asyncio
async def test_extend_nargs_2():
parser = CommandArgumentParser()
parser.add_argument("--pair", action=ArgumentAction.EXTEND, type=str, nargs=2)
args = await parser.parse_args(["--pair", "a", "b", "--pair", "c", "d"])
assert args["pair"] == ["a", "b", "c", "d"]
@pytest.mark.asyncio
async def test_extend_nargs_star():
parser = CommandArgumentParser()
parser.add_argument("--files", action=ArgumentAction.EXTEND, type=str, nargs="*")
args = await parser.parse_args(["--files", "x", "y", "z"])
assert args["files"] == ["x", "y", "z"]
args = await parser.parse_args(["--files"])
assert args["files"] == []
@pytest.mark.asyncio
async def test_extend_nargs_plus():
parser = CommandArgumentParser()
parser.add_argument("--inputs", action=ArgumentAction.EXTEND, type=int, nargs="+")
args = await parser.parse_args(["--inputs", "1", "2", "3", "--inputs", "4"])
assert args["inputs"] == [1, 2, 3, 4]
@pytest.mark.asyncio
async def test_extend_invalid_type():
parser = CommandArgumentParser()
parser.add_argument("--nums", action=ArgumentAction.EXTEND, type=int)
with pytest.raises(CommandArgumentError):
await parser.parse_args(["--nums", "a"])
@pytest.mark.asyncio
async def test_greedy_invalid_type():
parser = CommandArgumentParser()
parser.add_argument("--nums", nargs="*", type=int)
with pytest.raises(CommandArgumentError):
await parser.parse_args(["--nums", "a"])
@pytest.mark.asyncio
async def test_append_vs_extend_behavior():
parser = CommandArgumentParser()
parser.add_argument("--x", action=ArgumentAction.APPEND, nargs=2)
parser.add_argument("--y", action=ArgumentAction.EXTEND, nargs=2)
args = await parser.parse_args(
["--x", "a", "b", "--x", "c", "d", "--y", "1", "2", "--y", "3", "4"]
)
assert args["x"] == [["a", "b"], ["c", "d"]]
assert args["y"] == ["1", "2", "3", "4"]
@pytest.mark.asyncio
async def test_append_vs_extend_behavior_error():
parser = CommandArgumentParser()
parser.add_argument("--x", action=ArgumentAction.APPEND, nargs=2)
parser.add_argument("--y", action=ArgumentAction.EXTEND, nargs=2)
# This should raise an error because the last argument is not a valid pair
with pytest.raises(CommandArgumentError):
await parser.parse_args(
["--x", "a", "b", "--x", "c", "d", "--y", "1", "2", "--y", "3"]
)
with pytest.raises(CommandArgumentError):
await parser.parse_args(
["--x", "a", "b", "--x", "c", "--y", "1", "--y", "3", "4"]
)
@pytest.mark.asyncio
async def test_extend_positional():
parser = CommandArgumentParser()
parser.add_argument("files", action=ArgumentAction.EXTEND, type=str, nargs="*")
args = await parser.parse_args(["a", "b", "c"])
assert args["files"] == ["a", "b", "c"]
args = await parser.parse_args([])
assert args["files"] == []
@pytest.mark.asyncio
async def test_extend_positional_nargs():
parser = CommandArgumentParser()
parser.add_argument("files", action=ArgumentAction.EXTEND, type=str, nargs="+")
args = await parser.parse_args(["a", "b", "c"])
assert args["files"] == ["a", "b", "c"]
with pytest.raises(CommandArgumentError):
await parser.parse_args([])
def test_command_argument_parser_equality():
parser1 = CommandArgumentParser()
parser2 = CommandArgumentParser()
parser1.add_argument("--foo", type=str)
parser2.add_argument("--foo", type=str)
assert parser1 == parser2
parser1.add_argument("--bar", type=int)
assert parser1 != parser2
parser2.add_argument("--bar", type=int)
assert parser1 == parser2
assert parser1 != "not a parser"
assert parser1 is not None
assert parser1 != object()
assert parser1.to_definition_list() == parser2.to_definition_list()
assert hash(parser1) == hash(parser2)
@pytest.mark.asyncio
async def test_render_help():
parser = CommandArgumentParser()
parser.add_argument("--foo", type=str, help="Foo help")
parser.add_argument("--bar", action=ArgumentAction.APPEND, type=str, help="Bar help")
assert parser.render_help() is None

52
tests/test_main.py Normal file
View File

@ -0,0 +1,52 @@
import os
import shutil
import sys
from pathlib import Path
import pytest
from falyx.__main__ import bootstrap, find_falyx_config, main
def test_find_falyx_config():
"""Test if the falyx config file is found correctly."""
config_file = Path("falyx.yaml").resolve()
config_file.touch()
config_path = find_falyx_config()
assert config_path == config_file
config_file.unlink()
def test_bootstrap():
"""Test if the bootstrap function works correctly."""
config_file = Path("falyx.yaml").resolve()
config_file.touch()
sys_path_before = list(sys.path)
bootstrap_path = bootstrap()
assert bootstrap_path == config_file
assert str(config_file.parent) in sys.path
config_file.unlink()
sys.path = sys_path_before
def test_bootstrap_no_config():
"""Test if the bootstrap function works correctly when no config file is found."""
sys_path_before = list(sys.path)
bootstrap_path = bootstrap()
assert bootstrap_path is None
assert sys.path == sys_path_before
# assert str(Path.cwd()) not in sys.path
def test_bootstrap_with_global_config():
"""Test if the bootstrap function works correctly when a global config file is found."""
config_file = Path.home() / ".config" / "falyx" / "falyx.yaml"
config_file.parent.mkdir(parents=True, exist_ok=True)
config_file.touch()
sys_path_before = list(sys.path)
bootstrap_path = bootstrap()
assert bootstrap_path == config_file
assert str(config_file.parent) in sys.path
config_file.unlink()
sys.path = sys_path_before

View File

@ -0,0 +1,227 @@
import pytest
from falyx.action import Action, SelectionAction
from falyx.exceptions import CommandArgumentError
from falyx.parsers import ArgumentAction, CommandArgumentParser
def test_add_argument():
"""Test the add_argument method."""
parser = CommandArgumentParser()
action = Action("test_action", lambda: "value")
parser.add_argument(
"test", action=ArgumentAction.ACTION, help="Test argument", resolver=action
)
with pytest.raises(CommandArgumentError):
parser.add_argument("test1", action=ArgumentAction.ACTION, help="Test argument")
with pytest.raises(CommandArgumentError):
parser.add_argument(
"test2",
action=ArgumentAction.ACTION,
help="Test argument",
resolver="Not an action",
)
@pytest.mark.asyncio
async def test_falyx_actions():
"""Test the Falyx actions."""
parser = CommandArgumentParser()
action = Action("test_action", lambda: "value")
parser.add_argument(
"-a",
"--alpha",
action=ArgumentAction.ACTION,
resolver=action,
help="Alpha option",
)
# Test valid cases
args = await parser.parse_args(["-a"])
assert args["alpha"] == "value"
@pytest.mark.asyncio
async def test_action_basic():
parser = CommandArgumentParser()
action = Action("hello", lambda: "hi")
parser.add_argument("--greet", action=ArgumentAction.ACTION, resolver=action)
args = await parser.parse_args(["--greet"])
assert args["greet"] == "hi"
@pytest.mark.asyncio
async def test_action_with_nargs():
parser = CommandArgumentParser()
def multiply(a, b):
return int(a) * int(b)
action = Action("multiply", multiply)
parser.add_argument("--mul", action=ArgumentAction.ACTION, resolver=action, nargs=2)
args = await parser.parse_args(["--mul", "3", "4"])
assert args["mul"] == 12
@pytest.mark.asyncio
async def test_action_with_nargs_positional():
parser = CommandArgumentParser()
def multiply(a, b):
return int(a) * int(b)
action = Action("multiply", multiply)
parser.add_argument("mul", action=ArgumentAction.ACTION, resolver=action, nargs=2)
args = await parser.parse_args(["3", "4"])
assert args["mul"] == 12
with pytest.raises(CommandArgumentError):
await parser.parse_args(["3"])
with pytest.raises(CommandArgumentError):
await parser.parse_args([])
with pytest.raises(CommandArgumentError):
await parser.parse_args(["3", "4", "5"])
with pytest.raises(CommandArgumentError):
await parser.parse_args(["--mul", "3", "4"])
@pytest.mark.asyncio
async def test_action_with_nargs_positional_int():
parser = CommandArgumentParser()
def multiply(a, b):
return a * b
action = Action("multiply", multiply)
parser.add_argument(
"mul", action=ArgumentAction.ACTION, resolver=action, nargs=2, type=int
)
args = await parser.parse_args(["3", "4"])
assert args["mul"] == 12
with pytest.raises(CommandArgumentError):
await parser.parse_args(["3"])
with pytest.raises(CommandArgumentError):
await parser.parse_args(["abc", "3"])
@pytest.mark.asyncio
async def test_action_with_nargs_type():
parser = CommandArgumentParser()
def multiply(a, b):
return a * b
action = Action("multiply", multiply)
parser.add_argument(
"--mul", action=ArgumentAction.ACTION, resolver=action, nargs=2, type=int
)
args = await parser.parse_args(["--mul", "3", "4"])
assert args["mul"] == 12
with pytest.raises(CommandArgumentError):
await parser.parse_args(["--mul", "abc", "3"])
@pytest.mark.asyncio
async def test_action_with_custom_type():
parser = CommandArgumentParser()
def upcase(s):
return s.upper()
action = Action("upcase", upcase)
parser.add_argument("--word", action=ArgumentAction.ACTION, resolver=action, type=str)
args = await parser.parse_args(["--word", "hello"])
assert args["word"] == "HELLO"
@pytest.mark.asyncio
async def test_action_with_nargs_star():
parser = CommandArgumentParser()
def joiner(*args):
return "-".join(args)
action = Action("join", joiner)
parser.add_argument(
"--tags", action=ArgumentAction.ACTION, resolver=action, nargs="*"
)
args = await parser.parse_args(["--tags", "a", "b", "c"])
assert args["tags"] == "a-b-c"
@pytest.mark.asyncio
async def test_action_nargs_plus_missing():
parser = CommandArgumentParser()
action = Action("noop", lambda *args: args)
parser.add_argument("--x", action=ArgumentAction.ACTION, resolver=action, nargs="+")
with pytest.raises(CommandArgumentError):
await parser.parse_args(["--x"])
@pytest.mark.asyncio
async def test_action_with_default():
parser = CommandArgumentParser()
action = Action("default", lambda value: value)
parser.add_argument(
"--default",
action=ArgumentAction.ACTION,
resolver=action,
default="default_value",
)
args = await parser.parse_args([])
assert args["default"] == "default_value"
@pytest.mark.asyncio
async def test_action_with_default_and_value():
parser = CommandArgumentParser()
action = Action("default", lambda value: value)
parser.add_argument(
"--default",
action=ArgumentAction.ACTION,
resolver=action,
default="default_value",
)
args = await parser.parse_args(["--default", "new_value"])
assert args["default"] == "new_value"
@pytest.mark.asyncio
async def test_action_with_default_and_value_not():
parser = CommandArgumentParser()
action = Action("default", lambda: "default_value")
parser.add_argument(
"--default",
action=ArgumentAction.ACTION,
resolver=action,
default="default_value",
)
with pytest.raises(CommandArgumentError):
await parser.parse_args(["--default", "new_value"])
@pytest.mark.asyncio
async def test_action_with_default_and_value_positional():
parser = CommandArgumentParser()
action = Action("default", lambda: "default_value")
parser.add_argument("default", action=ArgumentAction.ACTION, resolver=action)
with pytest.raises(CommandArgumentError):
await parser.parse_args([])
with pytest.raises(CommandArgumentError):
await parser.parse_args(["be"])
# @pytest.mark.asyncio
# async def test_selection_action():
# parser = CommandArgumentParser()
# action = SelectionAction("select", selections=["a", "b", "c"])
# parser.add_argument("--select", action=ArgumentAction.ACTION, resolver=action)
# args = await parser.parse_args(["--select"])

View File

@ -0,0 +1,90 @@
import pytest
from falyx.parsers import Argument, ArgumentAction
def test_positional_text_with_choices():
arg = Argument(flags=("path",), dest="path", positional=True, choices=["a", "b"])
assert arg.get_positional_text() == "{a,b}"
def test_positional_text_without_choices():
arg = Argument(flags=("path",), dest="path", positional=True)
assert arg.get_positional_text() == "path"
@pytest.mark.parametrize(
"nargs,expected",
[
(None, "VALUE"),
(1, "VALUE"),
("?", "[VALUE]"),
("*", "[VALUE ...]"),
("+", "VALUE [VALUE ...]"),
],
)
def test_choice_text_store_action_variants(nargs, expected):
arg = Argument(
flags=("--value",), dest="value", action=ArgumentAction.STORE, nargs=nargs
)
assert arg.get_choice_text() == expected
@pytest.mark.parametrize(
"nargs,expected",
[
(None, "value"),
(1, "value"),
("?", "[value]"),
("*", "[value ...]"),
("+", "value [value ...]"),
],
)
def test_choice_text_store_action_variants_positional(nargs, expected):
arg = Argument(
flags=("value",),
dest="value",
action=ArgumentAction.STORE,
nargs=nargs,
positional=True,
)
assert arg.get_choice_text() == expected
def test_choice_text_with_choices():
arg = Argument(flags=("--mode",), dest="mode", choices=["dev", "prod"])
assert arg.get_choice_text() == "{dev,prod}"
def test_choice_text_append_and_extend():
for action in [ArgumentAction.APPEND, ArgumentAction.EXTEND]:
arg = Argument(flags=("--tag",), dest="tag", action=action)
assert arg.get_choice_text() == "TAG"
def test_equality():
a1 = Argument(flags=("--f",), dest="f")
a2 = Argument(flags=("--f",), dest="f")
a3 = Argument(flags=("-x",), dest="x")
assert a1 == a2
assert a1 != a3
assert hash(a1) == hash(a2)
def test_inequality_with_non_argument():
arg = Argument(flags=("--f",), dest="f")
assert arg != "not an argument"
def test_argument_equality():
arg = Argument("--foo", dest="foo", type=str, default="default_value")
arg2 = Argument("--foo", dest="foo", type=str, default="default_value")
arg3 = Argument("--bar", dest="bar", type=int, default=42)
arg4 = Argument("--foo", dest="foo", type=str, default="foobar")
assert arg == arg2
assert arg != arg3
assert arg != arg4
assert arg != "not an argument"
assert arg is not None
assert arg != object()

Some files were not shown because too many files have changed in this diff Show More