Compare commits
1 Commits
Author | SHA1 | Date |
---|---|---|
|
3fd27094d4 |
|
@ -15,3 +15,4 @@ build/
|
||||||
.vscode/
|
.vscode/
|
||||||
coverage.xml
|
coverage.xml
|
||||||
.coverage
|
.coverage
|
||||||
|
|
||||||
|
|
|
@ -1,23 +0,0 @@
|
||||||
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$
|
|
|
@ -52,8 +52,7 @@ poetry install
|
||||||
import asyncio
|
import asyncio
|
||||||
import random
|
import random
|
||||||
|
|
||||||
from falyx import Falyx
|
from falyx import Falyx, Action, ChainedAction
|
||||||
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():
|
||||||
|
@ -63,8 +62,8 @@ async def flaky_step():
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
# Create the actions
|
# Create the actions
|
||||||
step1 = Action(name="step_1", action=flaky_step)
|
step1 = Action(name="step_1", action=flaky_step, retry=True)
|
||||||
step2 = Action(name="step_2", action=flaky_step)
|
step2 = Action(name="step_2", action=flaky_step, retry=True)
|
||||||
|
|
||||||
# Chain the actions
|
# Chain the actions
|
||||||
chain = ChainedAction(name="my_pipeline", actions=[step1, step2])
|
chain = ChainedAction(name="my_pipeline", actions=[step1, step2])
|
||||||
|
@ -75,9 +74,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
|
||||||
|
|
|
@ -1,33 +1,29 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from falyx.action import Action, ActionGroup, ChainedAction
|
from falyx 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_action())
|
asyncio.run(hello())
|
||||||
|
|
||||||
|
|
||||||
# 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_action, goodbye_action])
|
group = ActionGroup(name="greeting_group", actions=[hello, goodbye])
|
||||||
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_action, goodbye_action])
|
chain = ChainedAction(name="greeting_chain", actions=[hello, goodbye])
|
||||||
asyncio.run(chain())
|
asyncio.run(chain())
|
||||||
|
|
|
@ -1,48 +0,0 @@
|
||||||
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())
|
|
|
@ -1,38 +0,0 @@
|
||||||
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())
|
|
|
@ -1,59 +0,0 @@
|
||||||
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())
|
|
|
@ -1,10 +0,0 @@
|
||||||
"""config_loading.py"""
|
|
||||||
|
|
||||||
from falyx.config import loader
|
|
||||||
|
|
||||||
flx = loader("falyx.yaml")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
asyncio.run(flx.run())
|
|
|
@ -1,32 +0,0 @@
|
||||||
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
|
|
|
@ -1,162 +0,0 @@
|
||||||
"""
|
|
||||||
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
|
|
|
@ -1,26 +0,0 @@
|
||||||
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())
|
|
|
@ -1,6 +0,0 @@
|
||||||
commands:
|
|
||||||
- key: T
|
|
||||||
description: HTTP Test
|
|
||||||
action: single_http.http_action
|
|
||||||
tags: [http, demo]
|
|
||||||
help_text: Run HTTP test.
|
|
|
@ -1,66 +0,0 @@
|
||||||
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())
|
|
|
@ -1,136 +0,0 @@
|
||||||
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())
|
|
|
@ -1,76 +0,0 @@
|
||||||
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())
|
|
|
@ -1,11 +0,0 @@
|
||||||
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
|
|
|
@ -1,36 +1,22 @@
|
||||||
|
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):
|
||||||
def generate_primes(start: int = 2, end: int = 100_000) -> list[int]:
|
primes = []
|
||||||
primes: list[int] = []
|
for num in range(2, n):
|
||||||
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(
|
console.print(f"Generated {len(primes)} primes up to {n}.", style=nc.GREEN)
|
||||||
f"Generated {len(primes)} primes from {start} to {end}.", style=nc.GREEN
|
|
||||||
)
|
|
||||||
return primes
|
return primes
|
||||||
|
|
||||||
|
|
||||||
actions = [ProcessTask(task=generate_primes)]
|
|
||||||
|
|
||||||
# Will not block the event loop
|
# Will not block the event loop
|
||||||
heavy_action = ProcessPoolAction(
|
heavy_action = ProcessAction("Prime Generator", generate_primes, args=(100_000,))
|
||||||
name="Prime Generator",
|
|
||||||
actions=actions,
|
|
||||||
)
|
|
||||||
|
|
||||||
falyx.add_command("R", "Generate Primes", heavy_action)
|
falyx.add_command("R", "Generate Primes", heavy_action, spinner=True)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
@ -1,32 +0,0 @@
|
||||||
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())
|
|
|
@ -1,30 +0,0 @@
|
||||||
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.")
|
|
|
@ -1,89 +0,0 @@
|
||||||
#!/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())
|
|
|
@ -1,22 +1,18 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import random
|
import random
|
||||||
|
|
||||||
from falyx import Falyx
|
from falyx import Falyx, Action, ChainedAction
|
||||||
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() -> str:
|
async def flaky_step():
|
||||||
await asyncio.sleep(0.2)
|
await asyncio.sleep(0.2)
|
||||||
if random.random() < 0.3:
|
if random.random() < 0.5:
|
||||||
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)
|
||||||
|
|
|
@ -1,14 +0,0 @@
|
||||||
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())
|
|
|
@ -1,53 +0,0 @@
|
||||||
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())
|
|
|
@ -1,100 +0,0 @@
|
||||||
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())
|
|
|
@ -1,38 +0,0 @@
|
||||||
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())
|
|
|
@ -1,18 +1,23 @@
|
||||||
"""
|
|
||||||
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",
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,120 +1,42 @@
|
||||||
"""
|
# 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 os
|
import logging
|
||||||
import sys
|
|
||||||
from argparse import ArgumentParser, Namespace, _SubParsersAction
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from falyx.config import loader
|
from falyx.action import Action
|
||||||
from falyx.falyx import Falyx
|
from falyx.falyx import Falyx
|
||||||
from falyx.parsers import CommandArgumentParser, get_root_parser, get_subparsers
|
|
||||||
|
|
||||||
|
|
||||||
def find_falyx_config() -> Path | None:
|
def build_falyx() -> Falyx:
|
||||||
candidates = [
|
"""Build and return a Falyx instance with all your commands."""
|
||||||
Path.cwd() / "falyx.yaml",
|
app = Falyx(title="🚀 Falyx CLI")
|
||||||
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
|
||||||
def bootstrap() -> Path | None:
|
app.add_command(
|
||||||
config_path = find_falyx_config()
|
key="B",
|
||||||
if config_path and str(config_path.parent) not in sys.path:
|
description="Build project",
|
||||||
sys.path.insert(0, str(config_path.parent))
|
action=Action("Build", lambda: print("📦 Building...")),
|
||||||
return config_path
|
tags=["build"]
|
||||||
|
|
||||||
|
|
||||||
def init_config(parser: CommandArgumentParser) -> None:
|
|
||||||
parser.add_argument(
|
|
||||||
"name",
|
|
||||||
type=str,
|
|
||||||
help="Name of the new Falyx project",
|
|
||||||
default=".",
|
|
||||||
nargs="?",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.add_command(
|
||||||
def init_callback(args: Namespace) -> None:
|
key="T",
|
||||||
"""Callback for the init command."""
|
description="Run tests",
|
||||||
if args.command == "init":
|
action=Action("Test", lambda: print("🧪 Running tests...")),
|
||||||
from falyx.init import init_project
|
tags=["test"]
|
||||||
|
|
||||||
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__":
|
||||||
main()
|
logging.basicConfig(level=logging.WARNING)
|
||||||
|
falyx = build_falyx()
|
||||||
|
asyncio.run(falyx.run())
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,537 @@
|
||||||
|
"""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
|
|
@ -1,45 +0,0 @@
|
||||||
"""
|
|
||||||
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",
|
|
||||||
]
|
|
|
@ -1,162 +0,0 @@
|
||||||
# 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})"
|
|
||||||
)
|
|
|
@ -1,126 +0,0 @@
|
||||||
# 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)
|
|
|
@ -1,170 +0,0 @@
|
||||||
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})"
|
|
||||||
)
|
|
|
@ -1,156 +0,0 @@
|
||||||
# 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)
|
|
|
@ -1,208 +0,0 @@
|
||||||
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})"
|
|
||||||
)
|
|
|
@ -1,49 +0,0 @@
|
||||||
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})"
|
|
|
@ -1,159 +0,0 @@
|
||||||
# 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})"
|
|
||||||
)
|
|
|
@ -1,265 +0,0 @@
|
||||||
# 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})"
|
|
||||||
)
|
|
|
@ -1,47 +0,0 @@
|
||||||
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})"
|
|
|
@ -1,162 +0,0 @@
|
||||||
# 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'})"
|
|
||||||
)
|
|
|
@ -1,33 +0,0 @@
|
||||||
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
|
|
|
@ -1,128 +0,0 @@
|
||||||
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})"
|
|
||||||
)
|
|
|
@ -1,166 +0,0 @@
|
||||||
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})"
|
|
||||||
)
|
|
|
@ -1,137 +0,0 @@
|
||||||
# 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'})"
|
|
||||||
)
|
|
|
@ -1,220 +0,0 @@
|
||||||
# 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})"
|
|
||||||
)
|
|
|
@ -1,320 +0,0 @@
|
||||||
# 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'})"
|
|
||||||
)
|
|
|
@ -1,43 +0,0 @@
|
||||||
# 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)
|
|
|
@ -1,52 +0,0 @@
|
||||||
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}")
|
|
|
@ -1,100 +0,0 @@
|
||||||
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})"
|
|
|
@ -1,4 +1,3 @@
|
||||||
# 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
|
||||||
|
@ -8,8 +7,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 import OneColors
|
from falyx.themes.colors import OneColors
|
||||||
from falyx.utils import CaseInsensitiveDict, chunks
|
from falyx.utils import CaseInsensitiveDict
|
||||||
|
|
||||||
|
|
||||||
class BottomBar:
|
class BottomBar:
|
||||||
|
@ -30,7 +29,8 @@ class BottomBar:
|
||||||
key_validator: Callable[[str], bool] | None = None,
|
key_validator: Callable[[str], bool] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.columns = columns
|
self.columns = columns
|
||||||
self.console = Console(color_system="truecolor")
|
self.console = Console()
|
||||||
|
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,7 +45,11 @@ 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(self, name: str, render_fn: Callable[[], HTML]) -> None:
|
def add_custom(
|
||||||
|
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")
|
||||||
|
@ -59,7 +63,9 @@ class BottomBar:
|
||||||
bg: str = OneColors.WHITE,
|
bg: str = OneColors.WHITE,
|
||||||
) -> None:
|
) -> None:
|
||||||
def render():
|
def render():
|
||||||
return HTML(f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>")
|
return HTML(
|
||||||
|
f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>"
|
||||||
|
)
|
||||||
|
|
||||||
self._add_named(name, render)
|
self._add_named(name, render)
|
||||||
|
|
||||||
|
@ -79,7 +85,9 @@ 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(f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>")
|
return HTML(
|
||||||
|
f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>"
|
||||||
|
)
|
||||||
|
|
||||||
self._add_named(name, render)
|
self._add_named(name, render)
|
||||||
|
|
||||||
|
@ -91,7 +99,6 @@ 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")
|
||||||
|
@ -101,12 +108,14 @@ 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 and enforce_total:
|
if current_value > 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(f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>")
|
return HTML(
|
||||||
|
f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>"
|
||||||
|
)
|
||||||
|
|
||||||
self._add_named(name, render)
|
self._add_named(name, render)
|
||||||
|
|
||||||
|
@ -128,9 +137,7 @@ 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(
|
raise ValueError(f"Key '{key}' conflicts with existing command, toggle, or reserved key.")
|
||||||
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)
|
||||||
|
|
||||||
|
@ -139,14 +146,16 @@ 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(f"<style bg='{color}' fg='{fg}'>{text:^{self.space}}</style>")
|
return HTML(
|
||||||
|
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 _(_):
|
def _(event):
|
||||||
toggle_state()
|
toggle_state()
|
||||||
|
|
||||||
def add_toggle_from_option(
|
def add_toggle_from_option(
|
||||||
|
@ -160,7 +169,6 @@ 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,
|
||||||
|
@ -177,33 +185,15 @@ 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):
|
||||||
"""Render the bottom bar."""
|
return merge_formatted_text([fn() for fn in self._items])
|
||||||
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]])
|
|
||||||
|
|
318
falyx/command.py
318
falyx/command.py
|
@ -1,24 +1,14 @@
|
||||||
# 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)
|
||||||
|
|
||||||
Defines the Command class for Falyx CLI.
|
This guarantees:
|
||||||
|
- Hook lifecycle (before/after/error/teardown)
|
||||||
Commands are callable units representing a menu option or CLI task,
|
- Timing
|
||||||
wrapping either a BaseAction or a simple function. They provide:
|
- Consistent return values
|
||||||
|
|
||||||
- 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
|
||||||
|
@ -26,93 +16,28 @@ 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.action import Action
|
from falyx.action import Action, BaseAction
|
||||||
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.retry_utils import enable_retries_recursively
|
from falyx.themes.colors import OneColors
|
||||||
from falyx.signals import CancelSignal
|
from falyx.utils import _noop, ensure_async, logger
|
||||||
from falyx.themes import OneColors
|
|
||||||
from falyx.utils import ensure_async
|
|
||||||
|
|
||||||
console = Console(color_system="truecolor")
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
action: BaseAction | Callable[..., Any]
|
aliases: list[str] = Field(default_factory=list)
|
||||||
|
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 = ""
|
||||||
help_epilog: str = ""
|
color: str = OneColors.WHITE
|
||||||
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
|
||||||
|
@ -127,55 +52,24 @@ 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)
|
||||||
|
|
||||||
async def parse_args(
|
def model_post_init(self, __context: Any) -> None:
|
||||||
self, raw_args: list[str] | str, from_validate: bool = False
|
"""Post-initialization to set up the action and hooks."""
|
||||||
) -> tuple[tuple, dict]:
|
if self.retry and isinstance(self.action, Action):
|
||||||
if callable(self.custom_parser):
|
self.action.enable_retry()
|
||||||
if isinstance(raw_args, str):
|
elif self.retry_policy and isinstance(self.action, Action):
|
||||||
try:
|
self.action.set_retry_policy(self.retry_policy)
|
||||||
raw_args = shlex.split(raw_args)
|
elif self.retry:
|
||||||
except ValueError:
|
logger.warning(f"[Command:{self.key}] Retry requested, but action is not an Action instance.")
|
||||||
logger.warning(
|
if self.retry_all:
|
||||||
"[Command:%s] Failed to split arguments: %s",
|
self.action.enable_retries_recursively(self.action, self.retry_policy)
|
||||||
self.key,
|
|
||||||
raw_args,
|
|
||||||
)
|
|
||||||
return ((), {})
|
|
||||||
return self.custom_parser(raw_args)
|
|
||||||
|
|
||||||
if isinstance(raw_args, str):
|
if self.logging_hooks and isinstance(self.action, BaseAction):
|
||||||
try:
|
register_debug_hooks(self.action.hooks)
|
||||||
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
|
||||||
|
@ -186,70 +80,11 @@ 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 get_argument_definitions(self) -> list[dict[str, Any]]:
|
def __str__(self):
|
||||||
if self.arguments:
|
return f"Command(key='{self.key}', description='{self.description}')"
|
||||||
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 []
|
|
||||||
|
|
||||||
def model_post_init(self, _: Any) -> None:
|
async def __call__(self, *args, **kwargs):
|
||||||
"""Post-initialization to set up the action and hooks."""
|
"""Run the action with full hook lifecycle, timing, and error handling."""
|
||||||
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(
|
||||||
|
@ -259,35 +94,20 @@ 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)
|
||||||
if self.spinner:
|
result = await self.action(*combined_args, **combined_kwargs)
|
||||||
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()
|
||||||
|
@ -304,7 +124,9 @@ 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([("class:confirm", self.confirm_message)])
|
return FormattedText([
|
||||||
|
("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):
|
||||||
|
@ -319,81 +141,27 @@ 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(
|
prompt.append((OneColors.DARK_YELLOW, f"with args={self.args}, kwargs={self.kwargs} "))
|
||||||
(OneColors.DARK_YELLOW, f"with args={self.args}, kwargs={self.kwargs} ")
|
|
||||||
)
|
|
||||||
|
|
||||||
return FormattedText(prompt)
|
return FormattedText(prompt)
|
||||||
|
|
||||||
@property
|
def log_summary(self):
|
||||||
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()
|
||||||
|
|
||||||
def show_help(self) -> bool:
|
async def preview(self):
|
||||||
"""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) and not isinstance(self.action, BaseAction):
|
elif callable(self.action):
|
||||||
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}")
|
||||||
if self.help_text:
|
console.print(f"[{OneColors.DARK_RED}]⚠️ Action is not callable or lacks a preview method.[/]")
|
||||||
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}')"
|
|
||||||
)
|
|
||||||
|
|
275
falyx/config.py
275
falyx/config.py
|
@ -1,27 +1,16 @@
|
||||||
# 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, Callable
|
from typing import Any
|
||||||
|
|
||||||
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.action import Action
|
from falyx.action import Action, BaseAction
|
||||||
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:
|
||||||
|
@ -31,8 +20,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__}'. "
|
f"Cannot wrap object of type '{type(obj).__name__}' as a BaseAction or Command. "
|
||||||
"Expected a function or BaseAction."
|
"It must be a callable or an instance of BaseAction."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -40,193 +29,14 @@ 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:
|
||||||
console.print(f"[{OneColors.DARK_RED}]❌ Invalid action path:[/] {dotted_path}")
|
raise ValueError(f"Invalid action path: {dotted_path}")
|
||||||
sys.exit(1)
|
module = importlib.import_module(module_path)
|
||||||
try:
|
return getattr(module, attr)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class RawCommand(BaseModel):
|
def loader(file_path: str) -> list[dict[str, Any]]:
|
||||||
"""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 Falyx CLI configuration from a YAML or TOML file.
|
Load command definitions 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
|
||||||
|
@ -237,19 +47,12 @@ def loader(file_path: Path | str, _depth: int = 0) -> Falyx:
|
||||||
file_path (str): Path to the config file (YAML or TOML).
|
file_path (str): Path to the config file (YAML or TOML).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Falyx: An instance of the Falyx CLI with loaded commands.
|
list[dict[str, Any]]: A list of command configuration dictionaries.
|
||||||
|
|
||||||
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.
|
||||||
"""
|
"""
|
||||||
if _depth > 5:
|
path = Path(file_path)
|
||||||
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}")
|
||||||
|
|
||||||
|
@ -262,25 +65,39 @@ def loader(file_path: Path | str, _depth: int = 0) -> Falyx:
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported config format: {suffix}")
|
raise ValueError(f"Unsupported config format: {suffix}")
|
||||||
|
|
||||||
if not isinstance(raw_config, dict):
|
if not isinstance(raw_config, list):
|
||||||
raise ValueError(
|
raise ValueError("Configuration file must contain a list of command definitions.")
|
||||||
"Configuration file must contain a dictionary with a list of commands.\n"
|
|
||||||
"Example:\n"
|
|
||||||
"title: 'My CLI'\n"
|
required = ["key", "description", "action"]
|
||||||
"commands:\n"
|
commands = []
|
||||||
" - key: 'a'\n"
|
for entry in raw_config:
|
||||||
" description: 'Example command'\n"
|
for field in required:
|
||||||
" action: 'my_module.my_function'"
|
if field not in entry:
|
||||||
)
|
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()
|
|
||||||
|
|
158
falyx/context.py
158
falyx/context.py
|
@ -1,22 +1,4 @@
|
||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
"""context.py"""
|
||||||
"""
|
|
||||||
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
|
||||||
|
@ -26,51 +8,9 @@ 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 = Field(default_factory=dict)
|
kwargs: dict = {}
|
||||||
action: Any
|
action: Any
|
||||||
result: Any | None = None
|
result: Any | None = None
|
||||||
exception: Exception | None = None
|
exception: Exception | None = None
|
||||||
|
@ -80,12 +20,8 @@ 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="truecolor"))
|
console: Console = Field(default_factory=lambda: Console(color_system="auto"))
|
||||||
|
|
||||||
shared_context: SharedContext | None = None
|
|
||||||
|
|
||||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
|
|
||||||
|
@ -97,13 +33,6 @@ 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:
|
||||||
|
@ -120,17 +49,6 @@ 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,
|
||||||
|
@ -140,32 +58,28 @@ class ExecutionContext(BaseModel):
|
||||||
"extra": self.extra,
|
"extra": self.extra,
|
||||||
}
|
}
|
||||||
|
|
||||||
def log_summary(self, logger=None) -> None:
|
def log_summary(self, logger=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_wall:
|
if self.end_time:
|
||||||
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 = (
|
exception_str = f"{type(self.exception).__name__}: {self.exception}" if self.exception else "None"
|
||||||
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}"
|
||||||
|
@ -173,11 +87,7 @@ 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 = (
|
result_str = f"Result: {repr(self.result)}" if self.success else f"Exception: {self.exception}"
|
||||||
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}>"
|
||||||
|
@ -193,58 +103,19 @@ class ExecutionContext(BaseModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SharedContext(BaseModel):
|
class ResultsContext(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:
|
||||||
|
@ -255,21 +126,14 @@ class SharedContext(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}SharedContext '{self.name}' | "
|
f"<{parallel_label}ResultsContext '{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
|
||||||
|
|
||||||
|
|
|
@ -1,16 +1,14 @@
|
||||||
# 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.logger import logger
|
from falyx.utils 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"{key}={value!r}" for key, value in context.kwargs.items())
|
kwargs = ", ".join(f"{k}={v!r}" for k, v 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):
|
||||||
|
@ -18,18 +16,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,
|
||||||
|
|
|
@ -1,7 +1,3 @@
|
||||||
# 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."""
|
||||||
|
|
||||||
|
@ -24,11 +20,3 @@ 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."""
|
|
||||||
|
|
|
@ -1,100 +1,34 @@
|
||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
"""execution_registry.py"""
|
||||||
"""
|
|
||||||
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 threading import Lock
|
from typing import Dict, List
|
||||||
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.logger import logger
|
from falyx.utils import logger
|
||||||
from falyx.themes import OneColors
|
|
||||||
|
|
||||||
|
|
||||||
class ExecutionRegistry:
|
class ExecutionRegistry:
|
||||||
"""
|
_store_by_name: Dict[str, List[ExecutionContext]] = defaultdict(list)
|
||||||
Global registry for recording and inspecting Falyx action executions.
|
_store_all: List[ExecutionContext] = []
|
||||||
|
|
||||||
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
|
||||||
|
@ -105,79 +39,11 @@ 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(
|
def summary(cls):
|
||||||
cls,
|
table = Table(title="[📊] Execution History", expand=True, box=box.SIMPLE)
|
||||||
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")
|
||||||
|
@ -185,32 +51,26 @@ 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 contexts:
|
for ctx in cls.get_all():
|
||||||
start = (
|
start = datetime.fromtimestamp(ctx.start_time).strftime("%H:%M:%S") if ctx.start_time else "n/a"
|
||||||
datetime.fromtimestamp(ctx.start_time).strftime("%H:%M:%S")
|
end = datetime.fromtimestamp(ctx.end_time).strftime("%H:%M:%S") if ctx.end_time else "n/a"
|
||||||
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 and status.lower() in ["all", "error"]:
|
if ctx.exception:
|
||||||
final_status = f"[{OneColors.DARK_RED}]❌ Error"
|
status = "[bold red]❌ Error"
|
||||||
final_result = repr(ctx.exception)
|
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:
|
||||||
continue
|
status = "[green]✅ Success"
|
||||||
|
result = repr(ctx.result)
|
||||||
|
|
||||||
table.add_row(
|
table.add_row(ctx.name, start, end, duration, status, result)
|
||||||
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)
|
||||||
|
|
1033
falyx/falyx.py
1033
falyx/falyx.py
File diff suppressed because it is too large
Load Diff
|
@ -1,22 +1,21 @@
|
||||||
# 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, Union
|
from typing import Awaitable, Callable, Dict, List, Optional, Union
|
||||||
|
|
||||||
from falyx.context import ExecutionContext
|
from falyx.context import ExecutionContext
|
||||||
from falyx.logger import logger
|
from falyx.utils import logger
|
||||||
|
|
||||||
Hook = Union[
|
Hook = Union[
|
||||||
Callable[[ExecutionContext], None], Callable[[ExecutionContext], Awaitable[None]]
|
Callable[[ExecutionContext], 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"
|
||||||
|
@ -24,7 +23,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)
|
||||||
|
|
||||||
|
@ -34,20 +33,17 @@ 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 | str, hook: Hook):
|
def register(self, hook_type: HookType, hook: Hook):
|
||||||
"""Raises ValueError if the hook type is not supported."""
|
if hook_type not in HookType:
|
||||||
if not isinstance(hook_type, HookType):
|
raise ValueError(f"Unsupported hook type: {hook_type}")
|
||||||
hook_type = HookType(hook_type)
|
|
||||||
self._hooks[hook_type].append(hook)
|
self._hooks[hook_type].append(hook)
|
||||||
|
|
||||||
def clear(self, hook_type: HookType | None = None):
|
def clear(self, hook_type: Optional[HookType] = None):
|
||||||
if hook_type:
|
if hook_type:
|
||||||
self._hooks[hook_type] = []
|
self._hooks[hook_type] = []
|
||||||
else:
|
else:
|
||||||
|
@ -64,28 +60,9 @@ class HookManager:
|
||||||
else:
|
else:
|
||||||
hook(context)
|
hook(context)
|
||||||
except Exception as hook_error:
|
except Exception as hook_error:
|
||||||
logger.warning(
|
logger.warning(f"⚠️ Hook '{hook.__name__}' raised an exception during '{hook_type}'"
|
||||||
"[Hook:%s] raised an exception during '%s' for '%s': %s",
|
f" for '{context.name}': {hook_error}")
|
||||||
hook.__name__,
|
|
||||||
hook_type,
|
|
||||||
context.name,
|
|
||||||
hook_error,
|
|
||||||
)
|
|
||||||
|
|
||||||
if hook_type == HookType.ON_ERROR:
|
if hook_type == HookType.ON_ERROR:
|
||||||
assert isinstance(
|
assert isinstance(context.exception, BaseException)
|
||||||
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)
|
|
||||||
|
|
|
@ -1,50 +1,32 @@
|
||||||
# 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.logger import logger
|
from falyx.themes.colors import OneColors
|
||||||
from falyx.themes import OneColors
|
from falyx.utils import logger
|
||||||
|
|
||||||
|
|
||||||
class ResultReporter:
|
class ResultReporter:
|
||||||
"""Reports the success of an action."""
|
def __init__(self, formatter: callable = None):
|
||||||
|
|
||||||
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 (self.default_formatter)
|
self.formatter = formatter or (lambda r: repr(r))
|
||||||
|
|
||||||
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 = (
|
duration = f"{context.duration:.3f}s" if context.duration is not None else "n/a"
|
||||||
f"{context.duration:.3f}s" if context.duration is not None else "n/a"
|
context.console.print(f"[{OneColors.GREEN}]✅ '{context.name}' "
|
||||||
)
|
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
|
||||||
|
@ -55,30 +37,21 @@ 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(
|
raise CircuitBreakerOpen(f"🔴 Circuit open for '{name}' until {time.ctime(self.open_until)}.")
|
||||||
f"Circuit open for '{name}' until {time.ctime(self.open_until)}."
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
logger.info("Circuit closed again for '%s'.")
|
logger.info(f"🟢 Circuit closed again for '{name}'.")
|
||||||
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(
|
logger.warning(f"⚠️ CircuitBreaker: '{name}' failure {self.failures}/{self.max_failures}.")
|
||||||
"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(
|
logger.error(f"🔴 Circuit opened for '{name}' until {time.ctime(self.open_until)}.")
|
||||||
"Circuit opened for '%s' until %s.", name, time.ctime(self.open_until)
|
|
||||||
)
|
|
||||||
|
|
||||||
def after_hook(self, _: ExecutionContext):
|
def after_hook(self, context: ExecutionContext):
|
||||||
self.failures = 0
|
self.failures = 0
|
||||||
|
|
||||||
def is_open(self):
|
def is_open(self):
|
||||||
|
@ -87,4 +60,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.")
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""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
135
falyx/init.py
|
@ -1,135 +0,0 @@
|
||||||
# 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")
|
|
|
@ -1,5 +0,0 @@
|
||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
|
||||||
"""logger.py"""
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger("falyx")
|
|
|
@ -0,0 +1,88 @@
|
||||||
|
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
105
falyx/menu.py
|
@ -1,105 +0,0 @@
|
||||||
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
|
|
|
@ -1,18 +1,15 @@
|
||||||
# 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.logger import logger
|
from falyx.utils import logger
|
||||||
|
|
||||||
|
|
||||||
class OptionsManager:
|
class OptionsManager:
|
||||||
"""OptionsManager"""
|
def __init__(self, namespaces: list[tuple[str, Namespace]] = None) -> None:
|
||||||
|
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)
|
||||||
|
@ -28,7 +25,9 @@ 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(self, option_name: str, value: Any, namespace_name: str = "cli_args") -> None:
|
def set(
|
||||||
|
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)
|
||||||
|
|
||||||
|
@ -44,9 +43,7 @@ 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(
|
logger.debug(f"Toggled '{option_name}' in '{namespace_name}' to {not current}")
|
||||||
"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"
|
||||||
|
|
|
@ -0,0 +1,100 @@
|
||||||
|
"""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,
|
||||||
|
)
|
|
@ -1,19 +0,0 @@
|
||||||
"""
|
|
||||||
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",
|
|
||||||
]
|
|
|
@ -1,949 +0,0 @@
|
||||||
# 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)
|
|
|
@ -1,272 +0,0 @@
|
||||||
# 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,
|
|
||||||
)
|
|
|
@ -1,80 +0,0 @@
|
||||||
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
|
|
|
@ -1,97 +0,0 @@
|
||||||
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
|
|
|
@ -1,48 +0,0 @@
|
||||||
# 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"
|
|
|
@ -1,17 +0,0 @@
|
||||||
# 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]: ...
|
|
|
@ -1,32 +1,18 @@
|
||||||
# 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.logger import logger
|
from falyx.utils 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.
|
||||||
|
@ -36,28 +22,18 @@ class RetryPolicy(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class RetryHandler:
|
class RetryHandler:
|
||||||
"""RetryHandler class to manage retry policies for actions."""
|
def __init__(self, policy: RetryPolicy=RetryPolicy()):
|
||||||
|
|
||||||
def __init__(self, policy: RetryPolicy = RetryPolicy()):
|
|
||||||
self.policy = policy
|
self.policy = policy
|
||||||
|
|
||||||
def enable_policy(
|
def enable_policy(self, backoff=2, max_retries=3, delay=1):
|
||||||
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
|
||||||
self.policy.jitter = jitter
|
logger.info(f"🔄 Retry policy enabled: {self.policy}")
|
||||||
logger.info("Retry policy enabled: %s", self.policy)
|
|
||||||
|
|
||||||
async def retry_on_error(self, context: ExecutionContext) -> None:
|
async def retry_on_error(self, context: ExecutionContext):
|
||||||
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
|
||||||
|
@ -67,55 +43,36 @@ class RetryHandler:
|
||||||
last_error = error
|
last_error = error
|
||||||
|
|
||||||
if not target:
|
if not target:
|
||||||
logger.warning("[%s] No action target. Cannot retry.", name)
|
logger.warning(f"[{name}] ⚠️ No action target. Cannot retry.")
|
||||||
return None
|
return
|
||||||
|
|
||||||
if not isinstance(target, Action):
|
if not isinstance(target, Action):
|
||||||
logger.warning(
|
logger.warning(f"[{name}] ❌ RetryHandler only supports only supports Action objects.")
|
||||||
"[%s] RetryHandler only supports only supports Action objects.", name
|
return
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not getattr(target, "is_retryable", False):
|
if not getattr(target, "is_retryable", False):
|
||||||
logger.warning("[%s] Not retryable.", name)
|
logger.warning(f"[{name}] ❌ Not retryable.")
|
||||||
return None
|
return
|
||||||
|
|
||||||
if not self.policy.enabled:
|
if not self.policy.enabled:
|
||||||
logger.warning("[%s] Retry policy is disabled.", name)
|
logger.warning(f"[{name}] ❌ Retry policy is disabled.")
|
||||||
return None
|
return
|
||||||
|
|
||||||
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("[%s] Retry succeeded on attempt %s.", name, retries_done)
|
logger.info(f"[{name}] ✅ Retry succeeded on attempt {retries_done}.")
|
||||||
return None
|
return
|
||||||
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(
|
logger.warning(f"[{name}] ⚠️ Retry attempt {retries_done}/{self.policy.max_retries} failed due to '{retry_error}'.")
|
||||||
"[%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("[%s] All %s retries failed.", name, self.policy.max_retries)
|
logger.error(f"[{name}] ❌ All {self.policy.max_retries} retries failed.")
|
||||||
|
return
|
||||||
|
|
|
@ -1,19 +0,0 @@
|
||||||
# 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)
|
|
|
@ -1,442 +0,0 @@
|
||||||
# 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,
|
|
||||||
)
|
|
|
@ -1,38 +0,0 @@
|
||||||
# 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)
|
|
|
@ -1,33 +0,0 @@
|
||||||
# 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
|
|
|
@ -1,15 +0,0 @@
|
||||||
"""
|
|
||||||
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",
|
|
||||||
]
|
|
|
@ -17,7 +17,6 @@ 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
|
||||||
|
|
||||||
|
@ -83,17 +82,14 @@ 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
|
key for key, val in cls.__dict__.items() if isinstance(val, str) and
|
||||||
for key, val in cls.__dict__.items()
|
not key.startswith("__")
|
||||||
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(
|
error_msg.append(f"Valid base color names include: {', '.join(valid_bases)}")
|
||||||
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):
|
||||||
|
@ -109,9 +105,7 @@ class ColorsMeta(type):
|
||||||
if mapped_style:
|
if mapped_style:
|
||||||
styles.append(mapped_style)
|
styles.append(mapped_style)
|
||||||
else:
|
else:
|
||||||
raise AttributeError(
|
raise AttributeError(f"Unknown style flag '{letter}' in attribute '{name}'")
|
||||||
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]])
|
||||||
|
@ -139,6 +133,7 @@ class OneColors(metaclass=ColorsMeta):
|
||||||
BLUE = "#61AFEF"
|
BLUE = "#61AFEF"
|
||||||
MAGENTA = "#C678DD"
|
MAGENTA = "#C678DD"
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def as_dict(cls):
|
def as_dict(cls):
|
||||||
"""
|
"""
|
||||||
|
@ -148,10 +143,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 not attr.startswith("__")
|
if not callable(getattr(cls, attr)) and
|
||||||
|
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.
|
||||||
|
@ -220,7 +215,8 @@ 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 not callable(getattr(cls, attr))
|
if attr.startswith("NORD") and
|
||||||
|
not callable(getattr(cls, attr))
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -231,8 +227,7 @@ class NordColors(metaclass=ColorsMeta):
|
||||||
"""
|
"""
|
||||||
skip_prefixes = ("NORD", "__")
|
skip_prefixes = ("NORD", "__")
|
||||||
alias_names = [
|
alias_names = [
|
||||||
attr
|
attr for attr in dir(cls)
|
||||||
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))
|
||||||
]
|
]
|
||||||
|
@ -269,6 +264,7 @@ 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
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
@ -281,6 +277,7 @@ 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
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
@ -295,12 +292,14 @@ 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
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
@ -315,6 +314,7 @@ 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(
|
"repr.url": Style(underline=True, color=NordColors.FROST_ICE, italic=False, bold=False),
|
||||||
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,6 +362,7 @@ 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
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
@ -370,10 +371,12 @@ 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
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
@ -381,6 +384,7 @@ 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
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
@ -389,6 +393,7 @@ 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
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
@ -400,6 +405,7 @@ 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
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
@ -417,11 +423,13 @@ 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
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
@ -430,12 +438,8 @@ 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(
|
"markdown.code": Style(bold=True, 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.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(),
|
||||||
|
@ -453,6 +457,7 @@ 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
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
@ -499,9 +504,7 @@ 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(
|
console.print("\n8) Raising and displaying a traceback with Nord styling:\n", style="bold")
|
||||||
"\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:
|
||||||
|
|
100
falyx/utils.py
100
falyx/utils.py
|
@ -1,54 +1,37 @@
|
||||||
# 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
|
||||||
|
|
||||||
|
|
||||||
|
@ -62,33 +45,41 @@ 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__(self._normalize_key(key), value)
|
super().__setitem__(key.upper(), value)
|
||||||
|
|
||||||
def __getitem__(self, key):
|
def __getitem__(self, key):
|
||||||
return super().__getitem__(self._normalize_key(key))
|
return super().__getitem__(key.upper())
|
||||||
|
|
||||||
def __contains__(self, key):
|
def __contains__(self, key):
|
||||||
return super().__contains__(self._normalize_key(key))
|
return super().__contains__(key.upper())
|
||||||
|
|
||||||
def get(self, key, default=None):
|
def get(self, key, default=None):
|
||||||
return super().get(self._normalize_key(key), default)
|
return super().get(key.upper(), default)
|
||||||
|
|
||||||
def pop(self, key, default=None):
|
def pop(self, key, default=None):
|
||||||
return super().pop(self._normalize_key(key), default)
|
return super().pop(key.upper(), default)
|
||||||
|
|
||||||
def update(self, other=None, **kwargs):
|
def update(self, other=None, **kwargs):
|
||||||
items = {}
|
|
||||||
if other:
|
if other:
|
||||||
items.update({self._normalize_key(k): v for k, v in other.items()})
|
other = {k.upper(): v for k, v in other.items()}
|
||||||
items.update({self._normalize_key(k): v for k, v in kwargs.items()})
|
kwargs = {k.upper(): v for k, v in kwargs.items()}
|
||||||
super().update(items)
|
super().update(other, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def running_in_container() -> bool:
|
def running_in_container() -> bool:
|
||||||
|
@ -113,13 +104,11 @@ 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
|
Configure logging for Falyx with support for both CLI-friendly and structured JSON output.
|
||||||
JSON output.
|
|
||||||
|
|
||||||
This function sets up separate logging handlers for console and file output,
|
This function sets up separate logging handlers for console and file output, with optional
|
||||||
with optional support for JSON formatting. It also auto-detects whether the
|
support for JSON formatting. It also auto-detects whether the application is running inside
|
||||||
application is running inside a container to default to machine-readable logs
|
a container to default to machine-readable logs when appropriate.
|
||||||
when appropriate.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
mode (str | None):
|
mode (str | None):
|
||||||
|
@ -142,8 +131,7 @@ 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
|
- Automatically sets logging levels for noisy third-party modules (`urllib3`, `asyncio`).
|
||||||
(`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:
|
||||||
|
@ -174,9 +162,7 @@ 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(
|
pythonjsonlogger.json.JsonFormatter("%(asctime)s %(name)s %(levelname)s %(message)s")
|
||||||
"%(asctime)s %(name)s %(levelname)s %(message)s"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Invalid log mode: {mode}")
|
raise ValueError(f"Invalid log mode: {mode}")
|
||||||
|
@ -184,21 +170,17 @@ 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, "a", "UTF-8")
|
file_handler = logging.FileHandler(log_filename)
|
||||||
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(
|
pythonjsonlogger.json.JsonFormatter("%(asctime)s %(name)s %(levelname)s %(message)s")
|
||||||
"%(asctime)s %(name)s %(levelname)s %(message)s"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
file_handler.setFormatter(
|
file_handler.setFormatter(logging.Formatter(
|
||||||
logging.Formatter(
|
"%(asctime)s [%(name)s] [%(levelname)s] %(message)s",
|
||||||
"%(asctime)s [%(name)s] [%(levelname)s] %(message)s",
|
datefmt="%Y-%m-%d %H:%M:%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)
|
||||||
|
|
|
@ -1,47 +0,0 @@
|
||||||
# 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'.")
|
|
|
@ -1 +1 @@
|
||||||
__version__ = "0.1.50"
|
__version__ = "0.1.5"
|
||||||
|
|
7
pylintrc
7
pylintrc
|
@ -146,10 +146,7 @@ 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]
|
||||||
|
@ -263,7 +260,7 @@ generated-members=
|
||||||
[FORMAT]
|
[FORMAT]
|
||||||
|
|
||||||
# Maximum number of characters on a single line.
|
# Maximum number of characters on a single line.
|
||||||
max-line-length=90
|
max-line-length=80
|
||||||
|
|
||||||
# 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.
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "falyx"
|
name = "falyx"
|
||||||
version = "0.1.50"
|
version = "0.1.5"
|
||||||
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,23 +13,15 @@ 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 = "^8.3.5"
|
pytest = "^7.0"
|
||||||
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.__main__:main"
|
falyx = "falyx.cli.main:main"
|
||||||
|
sync-version = "scripts.sync_version:main"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=1.0.0"]
|
requires = ["poetry-core>=1.0.0"]
|
||||||
|
@ -38,7 +30,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"]
|
||||||
|
|
|
@ -1,9 +1,7 @@
|
||||||
"""scripts/sync_version.py"""
|
"""scripts/sync_version.py"""
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import toml
|
import toml
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
|
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
|
||||||
|
@ -15,6 +13,5 @@ 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()
|
|
@ -1,223 +0,0 @@
|
||||||
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
|
|
|
@ -1,46 +0,0 @@
|
||||||
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()
|
|
|
@ -1,36 +0,0 @@
|
||||||
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
|
|
|
@ -1,52 +1,54 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
import asyncio
|
||||||
from falyx.action import Action, ActionGroup, ChainedAction, FallbackAction
|
import pickle
|
||||||
from falyx.context import ExecutionContext
|
import warnings
|
||||||
|
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():
|
async def test_action_runs_correctly(sample_action):
|
||||||
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):
|
||||||
async def a1():
|
action = Action(
|
||||||
return 42
|
name="hooked",
|
||||||
|
action=lambda: 42,
|
||||||
action = Action(name="hooked", action=a1, hooks=hook_manager)
|
hooks=hook_manager
|
||||||
|
)
|
||||||
|
|
||||||
await action()
|
await action()
|
||||||
|
|
||||||
|
@ -54,124 +56,67 @@ 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=a1),
|
Action(name="start", action=lambda: 1),
|
||||||
Action(name="add_last", action=a2, inject_last_result=True),
|
Action(name="add_last", action=lambda last_result: last_result + 5, inject_last_result=True),
|
||||||
Action(name="multiply", action=a3, inject_last_result=True),
|
Action(name="multiply", action=lambda last_result: last_result * 2, inject_last_result=True)
|
||||||
]
|
]
|
||||||
chain = ChainedAction(
|
|
||||||
name="test_chain", actions=actions, inject_last_result=True, return_list=True
|
|
||||||
)
|
|
||||||
result = await chain()
|
|
||||||
assert result == [1, 6, 12]
|
|
||||||
chain = ChainedAction(name="test_chain", actions=actions, inject_last_result=True)
|
chain = ChainedAction(name="test_chain", actions=actions, inject_last_result=True)
|
||||||
result = await chain()
|
result = await chain()
|
||||||
assert result == 12
|
assert result == [1, 6, 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=a1),
|
Action(name="a", action=lambda: 1),
|
||||||
Action(name="b", action=a2),
|
Action(name="b", action=lambda: 2),
|
||||||
Action(name="c", action=a3),
|
Action(name="c", action=lambda: 3),
|
||||||
]
|
]
|
||||||
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=a1, inject_last_result=True),
|
Action(name="inner_first", action=lambda last_result: last_result + 10, inject_last_result=True),
|
||||||
Action(name="inner_second", action=a2, inject_last_result=True),
|
Action(name="inner_second", action=lambda last_result: last_result + 5, inject_last_result=True),
|
||||||
],
|
]
|
||||||
return_list=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def a3():
|
|
||||||
return 1
|
|
||||||
|
|
||||||
async def a4(last_result):
|
|
||||||
return last_result + 2
|
|
||||||
|
|
||||||
actions = [
|
actions = [
|
||||||
Action(name="first", action=a3),
|
Action(name="first", action=lambda: 1),
|
||||||
Action(name="second", action=a4, inject_last_result=True),
|
Action(name="second", action=lambda last_result: last_result + 2, inject_last_result=True),
|
||||||
inner_chain,
|
inner_chain,
|
||||||
|
|
||||||
]
|
]
|
||||||
outer_chain = ChainedAction(name="test_chain", actions=actions, return_list=True)
|
outer_chain = ChainedAction(name="test_chain", actions=actions)
|
||||||
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=a1, inject_last_result=True),
|
Action(name="a", action=lambda last_result: last_result + 1, inject_last_result=True),
|
||||||
Action(name="b", action=a2, inject_last_result=True),
|
Action(name="b", action=lambda last_result: last_result + 2, inject_last_result=True),
|
||||||
Action(name="c", action=a3),
|
Action(name="c", action=lambda: 3),
|
||||||
],
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
async def a4():
|
|
||||||
return 1
|
|
||||||
|
|
||||||
async def a5(last_result):
|
|
||||||
return last_result + 2
|
|
||||||
|
|
||||||
actions = [
|
actions = [
|
||||||
Action(name="first", action=a4),
|
Action(name="first", action=lambda: 1),
|
||||||
Action(name="second", action=a5, inject_last_result=True),
|
Action(name="second", action=lambda last_result: last_result + 2, inject_last_result=True),
|
||||||
group,
|
group,
|
||||||
]
|
]
|
||||||
chain = ChainedAction(name="test_chain", actions=actions, return_list=True)
|
chain = ChainedAction(name="test_chain", actions=actions)
|
||||||
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():
|
||||||
|
@ -191,7 +136,6 @@ 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 = []
|
||||||
|
@ -207,7 +151,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)
|
||||||
|
@ -217,25 +161,37 @@ 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():
|
||||||
def hook(context):
|
hook = lambda ctx: ctx.extra.update({"test_marker": True})
|
||||||
context.extra.update({"test_marker": True})
|
|
||||||
|
|
||||||
async def a1():
|
chain = ChainedAction(name="chain", actions=[
|
||||||
return 1
|
Action(name="a", action=lambda: 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()
|
||||||
|
@ -243,7 +199,6 @@ 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():
|
||||||
|
@ -260,329 +215,16 @@ 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():
|
||||||
async def a1(last_result):
|
group = ActionGroup(name="group", actions=[
|
||||||
return last_result + 10
|
Action(name="g1", action=lambda last_result: last_result + 10, inject_last_result=True),
|
||||||
|
Action(name="g2", action=lambda last_result: last_result + 20, inject_last_result=True),
|
||||||
async def a2(last_result):
|
])
|
||||||
return last_result + 20
|
chain = ChainedAction(name="with_group", actions=[
|
||||||
|
Action(name="first", action=lambda: 5),
|
||||||
group = ActionGroup(
|
group,
|
||||||
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",
|
|
||||||
]
|
|
||||||
|
|
|
@ -1,25 +0,0 @@
|
||||||
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"]
|
|
|
@ -1,28 +0,0 @@
|
||||||
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)
|
|
|
@ -1,165 +0,0 @@
|
||||||
# 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"
|
|
|
@ -1,828 +0,0 @@
|
||||||
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
|
|
|
@ -1,52 +0,0 @@
|
||||||
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
|
|
|
@ -1,227 +0,0 @@
|
||||||
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"])
|
|
|
@ -1,90 +0,0 @@
|
||||||
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
Loading…
Reference in New Issue