Compare commits
37 Commits
command-ar
...
dc1764e752
Author | SHA1 | Date | |
---|---|---|---|
dc1764e752
|
|||
2288015cf3
|
|||
68d7d89d64
|
|||
9654b9926c
|
|||
294bbc9062
|
|||
4c1498121f
|
|||
ed42f6488e
|
|||
e2f0bf5903
|
|||
bb325684ac
|
|||
38f5f1e934
|
|||
2d1177e820
|
|||
3c7ef3eb1c
|
|||
53ba6a896a
|
|||
b24079ea7e
|
|||
ac82076511
|
|||
09eeb90dc6
|
|||
e3ebc1b17b
|
|||
079bc0ee77
|
|||
1c97857cb8
|
|||
21af003bc7
|
|||
1585098513
|
|||
3d3a706784
|
|||
c2eb854e5a
|
|||
8a3c1d6cc8
|
|||
f196e38c57
|
|||
fb1ffbe9f6
|
|||
429b434566
|
|||
4f3632bc6b
|
|||
ba562168aa
|
|||
ddb78bd5a7
|
|||
b0c0e7dc16
|
|||
0a1ba22a3d
|
|||
b51ba87999
|
|||
3c0a81359c
|
|||
4fa6e3bf1f | |||
afa47b0bac
|
|||
70a527358d |
@ -52,7 +52,8 @@ poetry install
|
||||
import asyncio
|
||||
import random
|
||||
|
||||
from falyx import Falyx, Action, ChainedAction
|
||||
from falyx import Falyx
|
||||
from falyx.action import Action, ChainedAction
|
||||
|
||||
# A flaky async step that fails randomly
|
||||
async def flaky_step():
|
||||
@ -62,8 +63,8 @@ async def flaky_step():
|
||||
return "ok"
|
||||
|
||||
# Create the actions
|
||||
step1 = Action(name="step_1", action=flaky_step, retry=True)
|
||||
step2 = Action(name="step_2", action=flaky_step, retry=True)
|
||||
step1 = Action(name="step_1", action=flaky_step)
|
||||
step2 = Action(name="step_2", action=flaky_step)
|
||||
|
||||
# Chain the actions
|
||||
chain = ChainedAction(name="my_pipeline", actions=[step1, step2])
|
||||
@ -74,9 +75,9 @@ falyx.add_command(
|
||||
key="R",
|
||||
description="Run My Pipeline",
|
||||
action=chain,
|
||||
logging_hooks=True,
|
||||
preview_before_confirm=True,
|
||||
confirm=True,
|
||||
retry_all=True,
|
||||
)
|
||||
|
||||
# Entry point
|
||||
|
@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
|
||||
from falyx import Action, ActionGroup, ChainedAction
|
||||
from falyx.action import Action, ActionGroup, ChainedAction
|
||||
|
||||
|
||||
# Actions can be defined as synchronous functions
|
||||
|
@ -1,12 +1,12 @@
|
||||
import asyncio
|
||||
|
||||
from falyx import Falyx
|
||||
from falyx.action import ActionFactoryAction, ChainedAction, HTTPAction, SelectionAction
|
||||
from falyx.action import ActionFactory, ChainedAction, HTTPAction, SelectionAction
|
||||
|
||||
# Selection of a post ID to fetch (just an example set)
|
||||
post_selector = SelectionAction(
|
||||
name="Pick Post ID",
|
||||
selections=["1", "2", "3", "4", "5"],
|
||||
selections=["15", "25", "35", "45", "55"],
|
||||
title="Choose a Post ID to submit",
|
||||
prompt_message="Post ID > ",
|
||||
show_table=True,
|
||||
@ -14,7 +14,7 @@ post_selector = SelectionAction(
|
||||
|
||||
|
||||
# Factory that builds and executes the actual HTTP POST request
|
||||
def build_post_action(post_id) -> HTTPAction:
|
||||
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})",
|
||||
@ -24,7 +24,7 @@ def build_post_action(post_id) -> HTTPAction:
|
||||
)
|
||||
|
||||
|
||||
post_factory = ActionFactoryAction(
|
||||
post_factory = ActionFactory(
|
||||
name="Build HTTPAction from Post ID",
|
||||
factory=build_post_action,
|
||||
inject_last_result=True,
|
||||
|
74
examples/argument_examples.py
Normal file
74
examples/argument_examples.py
Normal file
@ -0,0 +1,74 @@
|
||||
import asyncio
|
||||
from enum import Enum
|
||||
|
||||
from falyx import Falyx
|
||||
from falyx.action import Action
|
||||
from falyx.parser.command_argument_parser import CommandArgumentParser
|
||||
|
||||
|
||||
class Place(Enum):
|
||||
"""Enum for different places."""
|
||||
|
||||
NEW_YORK = "New York"
|
||||
SAN_FRANCISCO = "San Francisco"
|
||||
LONDON = "London"
|
||||
|
||||
def __str__(self):
|
||||
return self.value
|
||||
|
||||
|
||||
async def test_args(
|
||||
service: str,
|
||||
place: Place = Place.NEW_YORK,
|
||||
region: str = "us-east-1",
|
||||
verbose: bool | None = None,
|
||||
) -> str:
|
||||
if verbose:
|
||||
print(f"Deploying {service} to {region} at {place}...")
|
||||
return f"{service} deployed to {region} at {place}"
|
||||
|
||||
|
||||
def default_config(parser: CommandArgumentParser) -> None:
|
||||
"""Default argument configuration for the command."""
|
||||
parser.add_argument(
|
||||
"service",
|
||||
type=str,
|
||||
choices=["web", "database", "cache"],
|
||||
help="Service name to deploy.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"place",
|
||||
type=Place,
|
||||
choices=list(Place),
|
||||
default=Place.NEW_YORK,
|
||||
help="Place where the service will be deployed.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--region",
|
||||
type=str,
|
||||
default="us-east-1",
|
||||
help="Deployment region.",
|
||||
choices=["us-east-1", "us-west-2", "eu-west-1"],
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
action="store_bool_optional",
|
||||
help="Enable verbose output.",
|
||||
)
|
||||
|
||||
|
||||
flx = Falyx("Argument Examples")
|
||||
|
||||
flx.add_command(
|
||||
key="T",
|
||||
aliases=["test"],
|
||||
description="Test Command",
|
||||
help_text="A command to test argument parsing.",
|
||||
action=Action(
|
||||
name="test_args",
|
||||
action=test_args,
|
||||
),
|
||||
argument_config=default_config,
|
||||
)
|
||||
|
||||
asyncio.run(flx.run())
|
38
examples/auto_args_group.py
Normal file
38
examples/auto_args_group.py
Normal file
@ -0,0 +1,38 @@
|
||||
import asyncio
|
||||
|
||||
from falyx import Falyx
|
||||
from falyx.action import Action, ActionGroup
|
||||
|
||||
|
||||
# Define a shared async function
|
||||
async def say_hello(name: str, excited: bool = False):
|
||||
if excited:
|
||||
print(f"Hello, {name}!!!")
|
||||
else:
|
||||
print(f"Hello, {name}.")
|
||||
|
||||
|
||||
# Wrap the same callable in multiple Actions
|
||||
action1 = Action("say_hello_1", action=say_hello)
|
||||
action2 = Action("say_hello_2", action=say_hello)
|
||||
action3 = Action("say_hello_3", action=say_hello)
|
||||
|
||||
# Combine into an ActionGroup
|
||||
group = ActionGroup(name="greet_group", actions=[action1, action2, action3])
|
||||
|
||||
flx = Falyx("Test Group")
|
||||
flx.add_command(
|
||||
key="G",
|
||||
description="Greet someone with multiple variations.",
|
||||
aliases=["greet", "hello"],
|
||||
action=group,
|
||||
arg_metadata={
|
||||
"name": {
|
||||
"help": "The name of the person to greet.",
|
||||
},
|
||||
"excited": {
|
||||
"help": "Whether to greet excitedly.",
|
||||
},
|
||||
},
|
||||
)
|
||||
asyncio.run(flx.run())
|
62
examples/auto_parse_demo.py
Normal file
62
examples/auto_parse_demo.py
Normal file
@ -0,0 +1,62 @@
|
||||
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", "eu-west-1"],
|
||||
},
|
||||
"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())
|
121
examples/confirm_example.py
Normal file
121
examples/confirm_example.py
Normal file
@ -0,0 +1,121 @@
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from falyx import Falyx
|
||||
from falyx.action import (
|
||||
Action,
|
||||
ActionFactory,
|
||||
ChainedAction,
|
||||
ConfirmAction,
|
||||
SaveFileAction,
|
||||
)
|
||||
from falyx.parser import CommandArgumentParser
|
||||
|
||||
|
||||
class Dog(BaseModel):
|
||||
name: str
|
||||
age: int
|
||||
breed: str
|
||||
|
||||
|
||||
async def get_dogs(*dog_names: str) -> list[Dog]:
|
||||
"""Simulate fetching dog data."""
|
||||
await asyncio.sleep(0.1) # Simulate network delay
|
||||
dogs = [
|
||||
Dog(name="Buddy", age=3, breed="Golden Retriever"),
|
||||
Dog(name="Max", age=5, breed="Beagle"),
|
||||
Dog(name="Bella", age=2, breed="Bulldog"),
|
||||
Dog(name="Charlie", age=4, breed="Poodle"),
|
||||
Dog(name="Lucy", age=1, breed="Labrador"),
|
||||
Dog(name="Spot", age=6, breed="German Shepherd"),
|
||||
]
|
||||
dogs = [
|
||||
dog for dog in dogs if dog.name.upper() in (name.upper() for name in dog_names)
|
||||
]
|
||||
if not dogs:
|
||||
raise ValueError(f"No dogs found with the names: {', '.join(dog_names)}")
|
||||
return dogs
|
||||
|
||||
|
||||
async def build_json_updates(dogs: list[Dog]) -> list[dict[str, Any]]:
|
||||
"""Build JSON updates for the dogs."""
|
||||
print(f"Building JSON updates for {','.join(dog.name for dog in dogs)}")
|
||||
return [dog.model_dump(mode="json") for dog in dogs]
|
||||
|
||||
|
||||
async def save_dogs(dogs) -> None:
|
||||
if not dogs:
|
||||
print("No dogs processed.")
|
||||
return
|
||||
for result in dogs:
|
||||
print(f"Saving {Dog(**result)} to file.")
|
||||
await SaveFileAction(
|
||||
name="Save Dog Data",
|
||||
file_path=f"dogs/{result['name']}.json",
|
||||
data=result,
|
||||
file_type="json",
|
||||
)()
|
||||
|
||||
|
||||
async def build_chain(dogs: list[Dog]) -> ChainedAction:
|
||||
return ChainedAction(
|
||||
name="test_chain",
|
||||
actions=[
|
||||
Action(
|
||||
name="build_json_updates",
|
||||
action=build_json_updates,
|
||||
kwargs={"dogs": dogs},
|
||||
),
|
||||
ConfirmAction(
|
||||
name="test_confirm",
|
||||
message="Do you want to process the dogs?",
|
||||
confirm_type="yes_no_cancel",
|
||||
return_last_result=True,
|
||||
inject_into="dogs",
|
||||
),
|
||||
Action(
|
||||
name="save_dogs",
|
||||
action=save_dogs,
|
||||
inject_into="dogs",
|
||||
),
|
||||
],
|
||||
auto_inject=True,
|
||||
)
|
||||
|
||||
|
||||
factory = ActionFactory(
|
||||
name="Dog Post Factory",
|
||||
factory=build_chain,
|
||||
preview_kwargs={"dogs": ["Buddy", "Max"]},
|
||||
)
|
||||
|
||||
|
||||
def dog_config(parser: CommandArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"dogs",
|
||||
nargs="+",
|
||||
action="action",
|
||||
resolver=Action("Get Dogs", get_dogs),
|
||||
lazy_resolver=False,
|
||||
help="List of dogs to process.",
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
flx = Falyx("Save Dogs Example")
|
||||
|
||||
flx.add_command(
|
||||
key="D",
|
||||
description="Save Dog Data",
|
||||
action=factory,
|
||||
aliases=["save_dogs"],
|
||||
argument_config=dog_config,
|
||||
)
|
||||
|
||||
await flx.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
@ -3,7 +3,7 @@ commands:
|
||||
description: Pipeline Demo
|
||||
action: pipeline_demo.pipeline
|
||||
tags: [pipeline, demo]
|
||||
help_text: Run Demployment Pipeline with retries.
|
||||
help_text: Run Deployment Pipeline with retries.
|
||||
|
||||
- key: G
|
||||
description: Run HTTP Action Group
|
||||
|
@ -7,11 +7,9 @@ Licensed under the MIT License. See LICENSE file for details.
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
from argparse import Namespace
|
||||
|
||||
from falyx.action import Action, ActionGroup, ChainedAction
|
||||
from falyx.falyx import Falyx
|
||||
from falyx.parsers import FalyxParsers, get_arg_parsers
|
||||
from falyx.version import __version__
|
||||
|
||||
|
||||
@ -74,17 +72,10 @@ class Foo:
|
||||
await self.flx.run()
|
||||
|
||||
|
||||
def parse_args() -> Namespace:
|
||||
parsers: FalyxParsers = get_arg_parsers()
|
||||
return parsers.parse_args()
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Build and return a Falyx instance with all your commands."""
|
||||
args = parse_args()
|
||||
flx = Falyx(
|
||||
title="🚀 Falyx CLI",
|
||||
cli_args=args,
|
||||
columns=5,
|
||||
welcome_message="Welcome to Falyx CLI!",
|
||||
exit_message="Goodbye!",
|
||||
|
@ -2,18 +2,24 @@ import asyncio
|
||||
|
||||
from falyx import Falyx
|
||||
from falyx.action import SelectFileAction
|
||||
from falyx.action.types import FileReturnType
|
||||
from falyx.action.action_types import FileType
|
||||
|
||||
sf = SelectFileAction(
|
||||
name="select_file",
|
||||
suffix_filter=".py",
|
||||
suffix_filter=".yaml",
|
||||
title="Select a YAML file",
|
||||
prompt_message="Choose > ",
|
||||
return_type=FileReturnType.TEXT,
|
||||
prompt_message="Choose 2 > ",
|
||||
return_type=FileType.TEXT,
|
||||
columns=3,
|
||||
number_selections=2,
|
||||
)
|
||||
|
||||
flx = Falyx()
|
||||
flx = Falyx(
|
||||
title="File Selection Example",
|
||||
description="This example demonstrates how to select files using Falyx.",
|
||||
version="1.0.0",
|
||||
program="file_select.py",
|
||||
)
|
||||
|
||||
flx.add_command(
|
||||
key="S",
|
||||
|
@ -2,9 +2,8 @@ import asyncio
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from falyx import ActionGroup, Falyx
|
||||
from falyx.action import HTTPAction
|
||||
from falyx.hook_manager import HookType
|
||||
from falyx import Falyx
|
||||
from falyx.action import ActionGroup, HTTPAction
|
||||
from falyx.hooks import ResultReporter
|
||||
|
||||
console = Console()
|
||||
@ -49,7 +48,7 @@ action_group = ActionGroup(
|
||||
reporter = ResultReporter()
|
||||
|
||||
action_group.hooks.register(
|
||||
HookType.ON_SUCCESS,
|
||||
"on_success",
|
||||
reporter.report,
|
||||
)
|
||||
|
||||
|
@ -2,8 +2,16 @@ import asyncio
|
||||
import time
|
||||
|
||||
from falyx import Falyx
|
||||
from falyx.action import Action, ActionGroup, ChainedAction, MenuAction, ProcessAction
|
||||
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
|
||||
@ -77,20 +85,28 @@ parallel = ActionGroup(
|
||||
|
||||
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=MenuOptionMap(
|
||||
{
|
||||
"1": MenuOption("Run basic Action", basic_action),
|
||||
"2": MenuOption("Run ChainedAction", chained),
|
||||
"3": MenuOption("Run ActionGroup (parallel)", parallel),
|
||||
"4": MenuOption("Run ProcessAction (heavy task)", process),
|
||||
}
|
||||
),
|
||||
menu_options=menu_options,
|
||||
)
|
||||
|
||||
|
||||
prompt_menu = PromptMenuAction(
|
||||
name="select-user",
|
||||
menu_options=menu_options,
|
||||
)
|
||||
|
||||
flx = Falyx(
|
||||
@ -108,6 +124,13 @@ flx.add_command(
|
||||
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,9 +1,7 @@
|
||||
import asyncio
|
||||
|
||||
from falyx import Action, ActionGroup, ChainedAction
|
||||
from falyx import ExecutionRegistry as er
|
||||
from falyx import ProcessAction
|
||||
from falyx.hook_manager import HookType
|
||||
from falyx.action import Action, ActionGroup, ChainedAction, ProcessAction
|
||||
from falyx.retry import RetryHandler, RetryPolicy
|
||||
|
||||
|
||||
@ -47,7 +45,7 @@ def build_pipeline():
|
||||
checkout = Action("Checkout", checkout_code)
|
||||
analysis = ProcessAction("Static Analysis", run_static_analysis)
|
||||
tests = Action("Run Tests", flaky_tests)
|
||||
tests.hooks.register(HookType.ON_ERROR, retry_handler.retry_on_error)
|
||||
tests.hooks.register("on_error", retry_handler.retry_on_error)
|
||||
|
||||
# Parallel deploys
|
||||
deploy_group = ActionGroup(
|
||||
|
@ -1,25 +1,36 @@
|
||||
from rich.console import Console
|
||||
|
||||
from falyx import Falyx, ProcessAction
|
||||
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()
|
||||
falyx = Falyx(title="🚀 Process Pool Demo")
|
||||
|
||||
|
||||
def generate_primes(n):
|
||||
primes = []
|
||||
for num in range(2, n):
|
||||
def generate_primes(start: int = 2, end: int = 100_000) -> list[int]:
|
||||
primes: list[int] = []
|
||||
console.print(f"Generating primes from {start} to {end}...", style=nc.YELLOW)
|
||||
for num in range(start, end):
|
||||
if all(num % p != 0 for p in primes):
|
||||
primes.append(num)
|
||||
console.print(f"Generated {len(primes)} primes up to {n}.", style=nc.GREEN)
|
||||
console.print(
|
||||
f"Generated {len(primes)} primes from {start} to {end}.", style=nc.GREEN
|
||||
)
|
||||
return primes
|
||||
|
||||
|
||||
# Will not block the event loop
|
||||
heavy_action = ProcessAction("Prime Generator", generate_primes, args=(100_000,))
|
||||
actions = [ProcessTask(task=generate_primes)]
|
||||
|
||||
falyx.add_command("R", "Generate Primes", heavy_action, spinner=True)
|
||||
# Will not block the event loop
|
||||
heavy_action = ProcessPoolAction(
|
||||
name="Prime Generator",
|
||||
actions=actions,
|
||||
)
|
||||
|
||||
falyx.add_command("R", "Generate Primes", heavy_action)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
|
||||
from falyx import Action, Falyx
|
||||
from falyx import Falyx
|
||||
from falyx.action import Action
|
||||
|
||||
|
||||
async def main():
|
||||
|
@ -1,22 +1,70 @@
|
||||
import asyncio
|
||||
from uuid import uuid4
|
||||
|
||||
from falyx.selection import (
|
||||
SelectionOption,
|
||||
prompt_for_selection,
|
||||
render_selection_dict_table,
|
||||
)
|
||||
from falyx import Falyx
|
||||
from falyx.action import SelectionAction
|
||||
from falyx.selection import SelectionOption
|
||||
from falyx.signals import CancelSignal
|
||||
|
||||
menu = {
|
||||
"A": SelectionOption("Run diagnostics", lambda: print("Running diagnostics...")),
|
||||
"B": SelectionOption("Deploy to staging", lambda: print("Deploying...")),
|
||||
selections = {
|
||||
"1": SelectionOption(
|
||||
description="Production", value="3bc2616e-3696-11f0-a139-089204eb86ac"
|
||||
),
|
||||
"2": SelectionOption(
|
||||
description="Staging", value="42f2cd84-3696-11f0-a139-089204eb86ac"
|
||||
),
|
||||
}
|
||||
|
||||
table = render_selection_dict_table(
|
||||
title="Main Menu",
|
||||
selections=menu,
|
||||
|
||||
select = SelectionAction(
|
||||
name="Select Deployment",
|
||||
selections=selections,
|
||||
title="Select a Deployment",
|
||||
columns=2,
|
||||
prompt_message="> ",
|
||||
return_type="value",
|
||||
show_table=True,
|
||||
)
|
||||
|
||||
key = asyncio.run(prompt_for_selection(menu.keys(), table))
|
||||
print(f"You selected: {key}")
|
||||
list_selections = [uuid4() for _ in range(10)]
|
||||
|
||||
menu[key.upper()].value()
|
||||
list_select = SelectionAction(
|
||||
name="Select Deployments",
|
||||
selections=list_selections,
|
||||
title="Select Deployments",
|
||||
columns=3,
|
||||
prompt_message="Select 3 Deployments > ",
|
||||
return_type="value",
|
||||
show_table=True,
|
||||
number_selections=3,
|
||||
)
|
||||
|
||||
|
||||
flx = Falyx()
|
||||
|
||||
flx.add_command(
|
||||
key="S",
|
||||
description="Select a deployment",
|
||||
action=select,
|
||||
help_text="Select a deployment from the list",
|
||||
)
|
||||
flx.add_command(
|
||||
key="L",
|
||||
description="Select deployments",
|
||||
action=list_select,
|
||||
help_text="Select multiple deployments from the list",
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
try:
|
||||
print(asyncio.run(select()))
|
||||
except CancelSignal:
|
||||
print("Selection was cancelled.")
|
||||
|
||||
try:
|
||||
print(asyncio.run(list_select()))
|
||||
except CancelSignal:
|
||||
print("Selection was cancelled.")
|
||||
|
||||
asyncio.run(flx.run())
|
||||
|
@ -1,9 +1,8 @@
|
||||
#!/usr/bin/env python
|
||||
import asyncio
|
||||
|
||||
from falyx import Action, ChainedAction, Falyx
|
||||
from falyx.action import ShellAction
|
||||
from falyx.hook_manager import HookType
|
||||
from falyx import Falyx
|
||||
from falyx.action import Action, ChainedAction, ShellAction
|
||||
from falyx.hooks import ResultReporter
|
||||
from falyx.utils import setup_logging
|
||||
|
||||
@ -42,12 +41,12 @@ reporter = ResultReporter()
|
||||
|
||||
a1 = Action("a1", a1, inject_last_result=True)
|
||||
a1.hooks.register(
|
||||
HookType.ON_SUCCESS,
|
||||
"on_success",
|
||||
reporter.report,
|
||||
)
|
||||
a2 = Action("a2", a2, inject_last_result=True)
|
||||
a2.hooks.register(
|
||||
HookType.ON_SUCCESS,
|
||||
"on_success",
|
||||
reporter.report,
|
||||
)
|
||||
|
||||
|
@ -1,7 +1,8 @@
|
||||
import asyncio
|
||||
import random
|
||||
|
||||
from falyx import Action, ChainedAction, Falyx
|
||||
from falyx import Falyx
|
||||
from falyx.action import Action, ChainedAction
|
||||
from falyx.utils import setup_logging
|
||||
|
||||
setup_logging()
|
||||
|
@ -1,7 +1,8 @@
|
||||
import asyncio
|
||||
import random
|
||||
|
||||
from falyx import Action, ChainedAction, Falyx
|
||||
from falyx import Falyx
|
||||
from falyx.action import Action, ChainedAction
|
||||
from falyx.utils import setup_logging
|
||||
|
||||
setup_logging()
|
||||
|
100
examples/type_validation.py
Normal file
100
examples/type_validation.py
Normal file
@ -0,0 +1,100 @@
|
||||
import asyncio
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from falyx import Falyx
|
||||
from falyx.parser 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())
|
@ -7,24 +7,13 @@ Licensed under the MIT License. See LICENSE file for details.
|
||||
|
||||
import logging
|
||||
|
||||
from .action.action import Action, ActionGroup, ChainedAction, ProcessAction
|
||||
from .command import Command
|
||||
from .context import ExecutionContext, SharedContext
|
||||
from .execution_registry import ExecutionRegistry
|
||||
from .falyx import Falyx
|
||||
from .hook_manager import HookType
|
||||
|
||||
logger = logging.getLogger("falyx")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Action",
|
||||
"ChainedAction",
|
||||
"ActionGroup",
|
||||
"ProcessAction",
|
||||
"Falyx",
|
||||
"Command",
|
||||
"ExecutionContext",
|
||||
"SharedContext",
|
||||
"ExecutionRegistry",
|
||||
"HookType",
|
||||
]
|
||||
|
@ -8,13 +8,13 @@ Licensed under the MIT License. See LICENSE file for details.
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from argparse import Namespace
|
||||
from argparse import ArgumentParser, Namespace, _SubParsersAction
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from falyx.config import loader
|
||||
from falyx.falyx import Falyx
|
||||
from falyx.parsers import FalyxParsers, get_arg_parsers
|
||||
from falyx.parser import CommandArgumentParser, get_root_parser, get_subparsers
|
||||
|
||||
|
||||
def find_falyx_config() -> Path | None:
|
||||
@ -39,44 +39,81 @@ def bootstrap() -> Path | None:
|
||||
return config_path
|
||||
|
||||
|
||||
def get_falyx_parsers() -> FalyxParsers:
|
||||
falyx_parsers: FalyxParsers = get_arg_parsers()
|
||||
init_parser = falyx_parsers.subparsers.add_parser(
|
||||
"init", help="Create a new Falyx CLI project"
|
||||
def init_config(parser: CommandArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"name",
|
||||
type=str,
|
||||
help="Name of the new Falyx project",
|
||||
default=".",
|
||||
nargs="?",
|
||||
)
|
||||
init_parser.add_argument("name", nargs="?", default=".", help="Project directory")
|
||||
falyx_parsers.subparsers.add_parser(
|
||||
"init-global", help="Set up ~/.config/falyx with example tasks"
|
||||
)
|
||||
return falyx_parsers
|
||||
|
||||
|
||||
def run(args: Namespace) -> Any:
|
||||
def init_callback(args: Namespace) -> None:
|
||||
"""Callback for the init command."""
|
||||
if args.command == "init":
|
||||
from falyx.init import init_project
|
||||
|
||||
init_project(args.name)
|
||||
return
|
||||
|
||||
if args.command == "init-global":
|
||||
elif args.command == "init_global":
|
||||
from falyx.init import init_global
|
||||
|
||||
init_global()
|
||||
return
|
||||
|
||||
|
||||
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:
|
||||
print("No Falyx config file found. Exiting.")
|
||||
return None
|
||||
from falyx.init import init_global, init_project
|
||||
|
||||
flx: Falyx = loader(bootstrap_path)
|
||||
return asyncio.run(flx.run())
|
||||
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()
|
||||
|
||||
def main():
|
||||
parsers = get_falyx_parsers()
|
||||
args = parsers.parse_args()
|
||||
run(args)
|
||||
return asyncio.run(
|
||||
flx.run(root_parser=root_parser, subparsers=subparsers, callback=init_callback)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
0
falyx/action/.pytyped
Normal file
0
falyx/action/.pytyped
Normal file
@ -5,21 +5,25 @@ Copyright (c) 2025 rtj.dev LLC.
|
||||
Licensed under the MIT License. See LICENSE file for details.
|
||||
"""
|
||||
|
||||
from .action import (
|
||||
Action,
|
||||
ActionGroup,
|
||||
BaseAction,
|
||||
ChainedAction,
|
||||
FallbackAction,
|
||||
LiteralInputAction,
|
||||
ProcessAction,
|
||||
)
|
||||
from .action_factory import ActionFactoryAction
|
||||
from .action import Action
|
||||
from .action_factory import ActionFactory
|
||||
from .action_group import ActionGroup
|
||||
from .base_action import BaseAction
|
||||
from .chained_action import ChainedAction
|
||||
from .confirm_action import ConfirmAction
|
||||
from .fallback_action import FallbackAction
|
||||
from .http_action import HTTPAction
|
||||
from .io_action import BaseIOAction, ShellAction
|
||||
from .io_action import BaseIOAction
|
||||
from .literal_input_action import LiteralInputAction
|
||||
from .load_file_action import LoadFileAction
|
||||
from .menu_action import MenuAction
|
||||
from .process_action import ProcessAction
|
||||
from .process_pool_action import ProcessPoolAction
|
||||
from .prompt_menu_action import PromptMenuAction
|
||||
from .save_file_action import SaveFileAction
|
||||
from .select_file_action import SelectFileAction
|
||||
from .selection_action import SelectionAction
|
||||
from .shell_action import ShellAction
|
||||
from .signal_action import SignalAction
|
||||
from .user_input_action import UserInputAction
|
||||
|
||||
@ -29,7 +33,7 @@ __all__ = [
|
||||
"BaseAction",
|
||||
"ChainedAction",
|
||||
"ProcessAction",
|
||||
"ActionFactoryAction",
|
||||
"ActionFactory",
|
||||
"HTTPAction",
|
||||
"BaseIOAction",
|
||||
"ShellAction",
|
||||
@ -40,4 +44,9 @@ __all__ = [
|
||||
"FallbackAction",
|
||||
"LiteralInputAction",
|
||||
"UserInputAction",
|
||||
"PromptMenuAction",
|
||||
"ProcessPoolAction",
|
||||
"LoadFileAction",
|
||||
"SaveFileAction",
|
||||
"ConfirmAction",
|
||||
]
|
||||
|
@ -1,167 +1,21 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""action.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.
|
||||
"""
|
||||
"""action.py"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
from abc import ABC, abstractmethod
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from functools import cached_property, partial
|
||||
from typing import Any, Callable
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from rich.console import Console
|
||||
from rich.tree import Tree
|
||||
|
||||
from falyx.context import ExecutionContext, SharedContext
|
||||
from falyx.debug import register_debug_hooks
|
||||
from falyx.exceptions import EmptyChainError
|
||||
from falyx.action.base_action import BaseAction
|
||||
from falyx.context import ExecutionContext
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import Hook, HookManager, HookType
|
||||
from falyx.hook_manager import HookManager, HookType
|
||||
from falyx.logger import logger
|
||||
from falyx.options_manager import OptionsManager
|
||||
from falyx.retry import RetryHandler, RetryPolicy
|
||||
from falyx.themes import OneColors
|
||||
from falyx.utils import ensure_async
|
||||
|
||||
|
||||
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').
|
||||
_requires_injection (bool): Whether the action requires input injection.
|
||||
"""
|
||||
|
||||
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._requires_injection: bool = False
|
||||
self._skip_in_chain: bool = False
|
||||
self.console = Console(color_system="auto")
|
||||
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")
|
||||
|
||||
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 requires_io_injection(self) -> bool:
|
||||
"""Checks to see if the action requires input injection."""
|
||||
return self._requires_injection
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return str(self)
|
||||
|
||||
|
||||
class Action(BaseAction):
|
||||
"""
|
||||
Action wraps a simple function or coroutine into a standard executable unit.
|
||||
@ -188,9 +42,9 @@ class Action(BaseAction):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
action: Callable[..., Any],
|
||||
action: Callable[..., Any] | Callable[..., Awaitable[Any]],
|
||||
*,
|
||||
rollback: Callable[..., Any] | None = None,
|
||||
rollback: Callable[..., Any] | Callable[..., Awaitable[Any]] | None = None,
|
||||
args: tuple[Any, ...] = (),
|
||||
kwargs: dict[str, Any] | None = None,
|
||||
hooks: HookManager | None = None,
|
||||
@ -215,19 +69,19 @@ class Action(BaseAction):
|
||||
self.enable_retry()
|
||||
|
||||
@property
|
||||
def action(self) -> Callable[..., Any]:
|
||||
def action(self) -> Callable[..., Awaitable[Any]]:
|
||||
return self._action
|
||||
|
||||
@action.setter
|
||||
def action(self, value: Callable[..., Any]):
|
||||
def action(self, value: Callable[..., Awaitable[Any]]):
|
||||
self._action = ensure_async(value)
|
||||
|
||||
@property
|
||||
def rollback(self) -> Callable[..., Any] | None:
|
||||
def rollback(self) -> Callable[..., Awaitable[Any]] | None:
|
||||
return self._rollback
|
||||
|
||||
@rollback.setter
|
||||
def rollback(self, value: Callable[..., Any] | None):
|
||||
def rollback(self, value: Callable[..., Awaitable[Any]] | None):
|
||||
if value is None:
|
||||
self._rollback = None
|
||||
else:
|
||||
@ -246,6 +100,13 @@ class Action(BaseAction):
|
||||
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})
|
||||
@ -268,7 +129,7 @@ class Action(BaseAction):
|
||||
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)
|
||||
logger.info("[%s] Recovered: %s", self.name, self.name)
|
||||
return context.result
|
||||
raise
|
||||
finally:
|
||||
@ -296,559 +157,6 @@ class Action(BaseAction):
|
||||
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})"
|
||||
)
|
||||
|
||||
|
||||
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})"
|
||||
|
||||
|
||||
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})"
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
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)
|
||||
|
||||
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)
|
||||
last_result = shared_context.last_result()
|
||||
try:
|
||||
if self.requires_io_injection() and last_result is not None:
|
||||
result = await prepared(**{prepared.inject_into: last_result})
|
||||
else:
|
||||
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
|
||||
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})"
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
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})"
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
async def _run(self, *args, **kwargs):
|
||||
if self.inject_last_result:
|
||||
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})"
|
||||
f"retry={self.retry_policy.enabled}, "
|
||||
f"rollback={self.rollback is not None})"
|
||||
)
|
||||
|
@ -1,10 +1,10 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""action_factory.py"""
|
||||
from typing import Any
|
||||
"""action_factory_action.py"""
|
||||
from typing import Any, Callable
|
||||
|
||||
from rich.tree import Tree
|
||||
|
||||
from falyx.action.action import BaseAction
|
||||
from falyx.action.base_action import BaseAction
|
||||
from falyx.context import ExecutionContext
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import HookType
|
||||
@ -14,7 +14,7 @@ from falyx.themes import OneColors
|
||||
from falyx.utils import ensure_async
|
||||
|
||||
|
||||
class ActionFactoryAction(BaseAction):
|
||||
class ActionFactory(BaseAction):
|
||||
"""
|
||||
Dynamically creates and runs another Action at runtime using a factory function.
|
||||
|
||||
@ -35,6 +35,8 @@ class ActionFactoryAction(BaseAction):
|
||||
*,
|
||||
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,
|
||||
):
|
||||
@ -44,6 +46,8 @@ class ActionFactoryAction(BaseAction):
|
||||
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 {}
|
||||
|
||||
@ -55,7 +59,12 @@ class ActionFactoryAction(BaseAction):
|
||||
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)",
|
||||
@ -85,7 +94,7 @@ class ActionFactoryAction(BaseAction):
|
||||
)
|
||||
if self.options_manager:
|
||||
generated_action.set_options_manager(self.options_manager)
|
||||
context.result = await generated_action(*args, **kwargs)
|
||||
context.result = await generated_action()
|
||||
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||
return context.result
|
||||
except Exception as error:
|
||||
@ -103,7 +112,16 @@ class ActionFactoryAction(BaseAction):
|
||||
tree = parent.add(label) if parent else Tree(label)
|
||||
|
||||
try:
|
||||
generated = await self.factory(*self.preview_args, **self.preview_kwargs)
|
||||
generated = None
|
||||
if self.args or self.kwargs:
|
||||
try:
|
||||
generated = await self.factory(*self.args, **self.kwargs)
|
||||
except TypeError:
|
||||
...
|
||||
|
||||
if not generated:
|
||||
generated = await self.factory(*self.preview_args, **self.preview_kwargs)
|
||||
|
||||
if isinstance(generated, BaseAction):
|
||||
await generated.preview(parent=tree)
|
||||
else:
|
||||
|
197
falyx/action/action_group.py
Normal file
197
falyx/action/action_group.py
Normal file
@ -0,0 +1,197 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""action_group.py"""
|
||||
import asyncio
|
||||
import random
|
||||
from typing import Any, Awaitable, Callable, Sequence
|
||||
|
||||
from rich.tree import Tree
|
||||
|
||||
from falyx.action.action import Action
|
||||
from falyx.action.action_mixins import ActionListMixin
|
||||
from falyx.action.base_action import BaseAction
|
||||
from falyx.context import ExecutionContext, SharedContext
|
||||
from falyx.exceptions import EmptyGroupError
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import Hook, HookManager, HookType
|
||||
from falyx.logger import logger
|
||||
from falyx.options_manager import OptionsManager
|
||||
from falyx.parser.utils import same_argument_definitions
|
||||
from falyx.themes.colors import OneColors
|
||||
|
||||
|
||||
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: (
|
||||
Sequence[BaseAction | Callable[..., Any] | Callable[..., Awaitable]] | 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",
|
||||
):
|
||||
super().__init__(
|
||||
name,
|
||||
hooks=hooks,
|
||||
inject_last_result=inject_last_result,
|
||||
inject_into=inject_into,
|
||||
)
|
||||
ActionListMixin.__init__(self)
|
||||
self.args = args
|
||||
self.kwargs = kwargs or {}
|
||||
if actions:
|
||||
self.set_actions(actions)
|
||||
|
||||
def _wrap_if_needed(self, action: BaseAction | Callable[..., 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 | Callable[..., 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 set_actions(self, actions: Sequence[BaseAction | Callable[..., Any]]) -> None:
|
||||
"""Replaces the current action list with a new one."""
|
||||
self.actions.clear()
|
||||
for action in actions:
|
||||
self.add_action(action)
|
||||
|
||||
def set_options_manager(self, options_manager: OptionsManager) -> None:
|
||||
super().set_options_manager(options_manager)
|
||||
for action in self.actions:
|
||||
action.set_options_manager(options_manager)
|
||||
|
||||
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
|
||||
arg_defs = same_argument_definitions(self.actions)
|
||||
if arg_defs:
|
||||
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]]:
|
||||
if not self.actions:
|
||||
raise EmptyGroupError(f"[{self.name}] No actions to execute.")
|
||||
|
||||
combined_args = args + self.args
|
||||
combined_kwargs = {**self.kwargs, **kwargs}
|
||||
|
||||
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(combined_kwargs)
|
||||
context = ExecutionContext(
|
||||
name=self.name,
|
||||
args=combined_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(*combined_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})"
|
||||
)
|
37
falyx/action/action_mixins.py
Normal file
37
falyx/action/action_mixins.py
Normal file
@ -0,0 +1,37 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""action_mixins.py"""
|
||||
from typing import Sequence
|
||||
|
||||
from falyx.action.base_action import BaseAction
|
||||
|
||||
|
||||
class ActionListMixin:
|
||||
"""Mixin for managing a list of actions."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.actions: list[BaseAction] = []
|
||||
|
||||
def set_actions(self, actions: Sequence[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
|
84
falyx/action/action_types.py
Normal file
84
falyx/action/action_types.py
Normal file
@ -0,0 +1,84 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""action_types.py"""
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class FileType(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) -> FileType:
|
||||
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 FileType: '{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}")
|
||||
|
||||
|
||||
class ConfirmType(Enum):
|
||||
"""Enum for different confirmation types."""
|
||||
|
||||
YES_NO = "yes_no"
|
||||
YES_CANCEL = "yes_cancel"
|
||||
YES_NO_CANCEL = "yes_no_cancel"
|
||||
TYPE_WORD = "type_word"
|
||||
TYPE_WORD_CANCEL = "type_word_cancel"
|
||||
OK_CANCEL = "ok_cancel"
|
||||
ACKNOWLEDGE = "acknowledge"
|
||||
|
||||
@classmethod
|
||||
def choices(cls) -> list[ConfirmType]:
|
||||
"""Return a list of all hook type choices."""
|
||||
return list(cls)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return the string representation of the confirm type."""
|
||||
return self.value
|
||||
|
||||
@classmethod
|
||||
def _missing_(cls, value: object) -> ConfirmType:
|
||||
if isinstance(value, str):
|
||||
for member in cls:
|
||||
if member.value == value.lower():
|
||||
return member
|
||||
valid = ", ".join(member.value for member in cls)
|
||||
raise ValueError(f"Invalid ConfirmType: '{value}'. Must be one of: {valid}")
|
156
falyx/action/base_action.py
Normal file
156
falyx/action/base_action.py
Normal file
@ -0,0 +1,156 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""base_action.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.console import console
|
||||
from falyx.context import SharedContext
|
||||
from falyx.debug import register_debug_hooks
|
||||
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 = console
|
||||
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)
|
241
falyx/action/chained_action.py
Normal file
241
falyx/action/chained_action.py
Normal file
@ -0,0 +1,241 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""chained_action.py"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Awaitable, Callable, Sequence
|
||||
|
||||
from rich.tree import Tree
|
||||
|
||||
from falyx.action.action import Action
|
||||
from falyx.action.action_mixins import ActionListMixin
|
||||
from falyx.action.base_action import BaseAction
|
||||
from falyx.action.fallback_action import FallbackAction
|
||||
from falyx.action.literal_input_action import LiteralInputAction
|
||||
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.options_manager import OptionsManager
|
||||
from falyx.signals import BreakChainSignal
|
||||
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: (
|
||||
Sequence[BaseAction | Callable[..., Any] | Callable[..., Awaitable[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",
|
||||
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.args = args
|
||||
self.kwargs = kwargs or {}
|
||||
self.auto_inject = auto_inject
|
||||
self.return_list = return_list
|
||||
if actions:
|
||||
self.set_actions(actions)
|
||||
|
||||
def _wrap_if_needed(self, action: BaseAction | Callable[..., 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 | Callable[..., 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 set_actions(self, actions: Sequence[BaseAction | Callable[..., Any]]) -> None:
|
||||
"""Replaces the current action list with a new one."""
|
||||
self.actions.clear()
|
||||
for action in actions:
|
||||
self.add_action(action)
|
||||
|
||||
def set_options_manager(self, options_manager: OptionsManager) -> None:
|
||||
super().set_options_manager(options_manager)
|
||||
for action in self.actions:
|
||||
action.set_options_manager(options_manager)
|
||||
|
||||
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
|
||||
if self.actions:
|
||||
return self.actions[0].get_infer_target()
|
||||
return None, None
|
||||
|
||||
def _clear_args(self):
|
||||
return (), {}
|
||||
|
||||
async def _run(self, *args, **kwargs) -> Any:
|
||||
if not self.actions:
|
||||
raise EmptyChainError(f"[{self.name}] No actions to execute.")
|
||||
|
||||
combined_args = args + self.args
|
||||
combined_kwargs = {**self.kwargs, **kwargs}
|
||||
|
||||
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(combined_kwargs)
|
||||
context = ExecutionContext(
|
||||
name=self.name,
|
||||
args=combined_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(*combined_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
|
||||
shared_context.add_result(result)
|
||||
context.extra["results"].append(result)
|
||||
context.extra["rollback_stack"].append(
|
||||
(prepared, combined_args, updated_kwargs)
|
||||
)
|
||||
combined_args, updated_kwargs = self._clear_args()
|
||||
|
||||
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 BreakChainSignal as error:
|
||||
logger.info("[%s] Chain broken: %s", self.name, error)
|
||||
context.exception = error
|
||||
shared_context.add_error(shared_context.current_index, error)
|
||||
await self._rollback(context.extra["rollback_stack"])
|
||||
except Exception as error:
|
||||
context.exception = error
|
||||
shared_context.add_error(shared_context.current_index, error)
|
||||
await self._rollback(context.extra["rollback_stack"])
|
||||
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: list[tuple[Action, tuple[Any, ...], dict[str, Any]]]
|
||||
):
|
||||
"""
|
||||
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, args, kwargs in reversed(rollback_stack):
|
||||
rollback = getattr(action, "rollback", None)
|
||||
if rollback:
|
||||
try:
|
||||
logger.warning("[%s] Rolling back...", action.name)
|
||||
await 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."""
|
||||
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.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})"
|
||||
)
|
215
falyx/action/confirm_action.py
Normal file
215
falyx/action/confirm_action.py
Normal file
@ -0,0 +1,215 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from prompt_toolkit import PromptSession
|
||||
from rich.tree import Tree
|
||||
|
||||
from falyx.action.action_types import ConfirmType
|
||||
from falyx.action.base_action import BaseAction
|
||||
from falyx.context import ExecutionContext
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import HookType
|
||||
from falyx.logger import logger
|
||||
from falyx.prompt_utils import confirm_async, should_prompt_user
|
||||
from falyx.signals import CancelSignal
|
||||
from falyx.themes import OneColors
|
||||
from falyx.validators import word_validator, words_validator
|
||||
|
||||
|
||||
class ConfirmAction(BaseAction):
|
||||
"""
|
||||
Action to confirm an operation with the user.
|
||||
|
||||
There are several ways to confirm an action, such as using a simple
|
||||
yes/no prompt. You can also use a confirmation type that requires the user
|
||||
to type a specific word or phrase to confirm the action, or use an OK/Cancel
|
||||
dialog.
|
||||
|
||||
This action can be used to ensure that the user explicitly agrees to proceed
|
||||
with an operation.
|
||||
|
||||
Attributes:
|
||||
name (str): Name of the action.
|
||||
message (str): The confirmation message to display.
|
||||
confirm_type (ConfirmType | str): The type of confirmation to use.
|
||||
Options include YES_NO, YES_CANCEL, YES_NO_CANCEL, TYPE_WORD, and OK_CANCEL.
|
||||
prompt_session (PromptSession | None): The session to use for input.
|
||||
confirm (bool): Whether to prompt the user for confirmation.
|
||||
word (str): The word to type for TYPE_WORD confirmation.
|
||||
return_last_result (bool): Whether to return the last result of the action
|
||||
instead of a boolean.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
message: str = "Confirm?",
|
||||
confirm_type: ConfirmType | str = ConfirmType.YES_NO,
|
||||
prompt_session: PromptSession | None = None,
|
||||
never_prompt: bool = False,
|
||||
word: str = "CONFIRM",
|
||||
return_last_result: bool = False,
|
||||
inject_last_result: bool = True,
|
||||
inject_into: str = "last_result",
|
||||
):
|
||||
"""
|
||||
Initialize the ConfirmAction.
|
||||
|
||||
Args:
|
||||
message (str): The confirmation message to display.
|
||||
confirm_type (ConfirmType): The type of confirmation to use.
|
||||
Options include YES_NO, YES_CANCEL, YES_NO_CANCEL, TYPE_WORD, and OK_CANCEL.
|
||||
prompt_session (PromptSession | None): The session to use for input.
|
||||
confirm (bool): Whether to prompt the user for confirmation.
|
||||
word (str): The word to type for TYPE_WORD confirmation.
|
||||
return_last_result (bool): Whether to return the last result of the action.
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
inject_last_result=inject_last_result,
|
||||
inject_into=inject_into,
|
||||
never_prompt=never_prompt,
|
||||
)
|
||||
self.message = message
|
||||
self.confirm_type = self._coerce_confirm_type(confirm_type)
|
||||
self.prompt_session = prompt_session or PromptSession()
|
||||
self.word = word
|
||||
self.return_last_result = return_last_result
|
||||
|
||||
def _coerce_confirm_type(self, confirm_type: ConfirmType | str) -> ConfirmType:
|
||||
"""Coerce the confirm_type to a ConfirmType enum."""
|
||||
if isinstance(confirm_type, ConfirmType):
|
||||
return confirm_type
|
||||
elif isinstance(confirm_type, str):
|
||||
return ConfirmType(confirm_type)
|
||||
return ConfirmType(confirm_type)
|
||||
|
||||
async def _confirm(self) -> bool:
|
||||
"""Confirm the action with the user."""
|
||||
match self.confirm_type:
|
||||
case ConfirmType.YES_NO:
|
||||
return await confirm_async(
|
||||
self.message,
|
||||
prefix="❓ ",
|
||||
suffix=" [Y/n] > ",
|
||||
session=self.prompt_session,
|
||||
)
|
||||
case ConfirmType.YES_NO_CANCEL:
|
||||
error_message = "Enter 'Y', 'y' to confirm, 'N', 'n' to decline, or 'C', 'c' to abort."
|
||||
answer = await self.prompt_session.prompt_async(
|
||||
f"❓ {self.message} [Y]es, [N]o, or [C]ancel to abort > ",
|
||||
validator=words_validator(
|
||||
["Y", "N", "C"], error_message=error_message
|
||||
),
|
||||
)
|
||||
if answer.upper() == "C":
|
||||
raise CancelSignal(f"Action '{self.name}' was cancelled by the user.")
|
||||
return answer.upper() == "Y"
|
||||
case ConfirmType.TYPE_WORD:
|
||||
answer = await self.prompt_session.prompt_async(
|
||||
f"❓ {self.message} [{self.word}] to confirm or [N/n] > ",
|
||||
validator=word_validator(self.word),
|
||||
)
|
||||
return answer.upper().strip() != "N"
|
||||
case ConfirmType.TYPE_WORD_CANCEL:
|
||||
answer = await self.prompt_session.prompt_async(
|
||||
f"❓ {self.message} [{self.word}] to confirm or [N/n] > ",
|
||||
validator=word_validator(self.word),
|
||||
)
|
||||
if answer.upper().strip() == "N":
|
||||
raise CancelSignal(f"Action '{self.name}' was cancelled by the user.")
|
||||
return answer.upper().strip() == self.word.upper().strip()
|
||||
case ConfirmType.YES_CANCEL:
|
||||
answer = await confirm_async(
|
||||
self.message,
|
||||
prefix="❓ ",
|
||||
suffix=" [Y/n] > ",
|
||||
session=self.prompt_session,
|
||||
)
|
||||
if not answer:
|
||||
raise CancelSignal(f"Action '{self.name}' was cancelled by the user.")
|
||||
return answer
|
||||
case ConfirmType.OK_CANCEL:
|
||||
error_message = "Enter 'O', 'o' to confirm or 'C', 'c' to abort."
|
||||
answer = await self.prompt_session.prompt_async(
|
||||
f"❓ {self.message} [O]k to confirm, [C]ancel to abort > ",
|
||||
validator=words_validator(["O", "C"], error_message=error_message),
|
||||
)
|
||||
if answer.upper() == "C":
|
||||
raise CancelSignal(f"Action '{self.name}' was cancelled by the user.")
|
||||
return answer.upper() == "O"
|
||||
case ConfirmType.ACKNOWLEDGE:
|
||||
answer = await self.prompt_session.prompt_async(
|
||||
f"❓ {self.message} [A]cknowledge > ",
|
||||
validator=word_validator("A"),
|
||||
)
|
||||
return answer.upper().strip() == "A"
|
||||
case _:
|
||||
raise ValueError(f"Unknown confirm_type: {self.confirm_type}")
|
||||
|
||||
def get_infer_target(self) -> tuple[None, None]:
|
||||
return None, None
|
||||
|
||||
async def _run(self, *args, **kwargs) -> Any:
|
||||
combined_kwargs = self._maybe_inject_last_result(kwargs)
|
||||
context = ExecutionContext(
|
||||
name=self.name, args=args, kwargs=combined_kwargs, action=self
|
||||
)
|
||||
context.start_timer()
|
||||
try:
|
||||
await self.hooks.trigger(HookType.BEFORE, context)
|
||||
if (
|
||||
self.never_prompt
|
||||
or self.options_manager
|
||||
and not should_prompt_user(confirm=True, options=self.options_manager)
|
||||
):
|
||||
logger.debug(
|
||||
"Skipping confirmation for '%s' due to never_prompt or options_manager settings.",
|
||||
self.name,
|
||||
)
|
||||
if self.return_last_result:
|
||||
result = combined_kwargs[self.inject_into]
|
||||
else:
|
||||
result = True
|
||||
else:
|
||||
answer = await self._confirm()
|
||||
if self.return_last_result and answer:
|
||||
result = combined_kwargs[self.inject_into]
|
||||
else:
|
||||
result = answer
|
||||
logger.debug("Action '%s' confirmed with result: %s", self.name, result)
|
||||
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||
return result
|
||||
except Exception as error:
|
||||
context.exception = error
|
||||
await self.hooks.trigger(HookType.ON_ERROR, context)
|
||||
raise
|
||||
finally:
|
||||
context.stop_timer()
|
||||
await self.hooks.trigger(HookType.AFTER, context)
|
||||
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
||||
er.record(context)
|
||||
|
||||
async def preview(self, parent: Tree | None = None) -> None:
|
||||
tree = (
|
||||
Tree(
|
||||
f"[{OneColors.CYAN_b}]ConfirmAction[/]: {self.name}",
|
||||
guide_style=OneColors.BLUE_b,
|
||||
)
|
||||
if not parent
|
||||
else parent.add(f"[{OneColors.CYAN_b}]ConfirmAction[/]: {self.name}")
|
||||
)
|
||||
tree.add(f"[bold]Message:[/] {self.message}")
|
||||
tree.add(f"[bold]Type:[/] {self.confirm_type.value}")
|
||||
tree.add(f"[bold]Prompt Required:[/] {'No' if self.never_prompt else 'Yes'}")
|
||||
if self.confirm_type in (ConfirmType.TYPE_WORD, ConfirmType.TYPE_WORD_CANCEL):
|
||||
tree.add(f"[bold]Confirmation Word:[/] {self.word}")
|
||||
if parent is None:
|
||||
self.console.print(tree)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f"ConfirmAction(name={self.name}, message={self.message}, "
|
||||
f"confirm_type={self.confirm_type}, return_last_result={self.return_last_result})"
|
||||
)
|
51
falyx/action/fallback_action.py
Normal file
51
falyx/action/fallback_action.py
Normal file
@ -0,0 +1,51 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""fallback_action.py"""
|
||||
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})"
|
@ -28,7 +28,7 @@ async def close_shared_http_session(context: ExecutionContext) -> None:
|
||||
if session and should_close:
|
||||
await session.close()
|
||||
except Exception as error:
|
||||
logger.warning("⚠️ Error closing shared HTTP session: %s", error)
|
||||
logger.warning("Error closing shared HTTP session: %s", error)
|
||||
|
||||
|
||||
class HTTPAction(Action):
|
||||
|
@ -16,19 +16,15 @@ 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
|
||||
from typing import Any, Callable
|
||||
|
||||
from rich.tree import Tree
|
||||
|
||||
from falyx.action.action import BaseAction
|
||||
from falyx.action.base_action import BaseAction
|
||||
from falyx.context import ExecutionContext
|
||||
from falyx.exceptions import FalyxError
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import HookManager, HookType
|
||||
from falyx.logger import logger
|
||||
from falyx.themes import OneColors
|
||||
|
||||
|
||||
@ -73,7 +69,6 @@ class BaseIOAction(BaseAction):
|
||||
inject_last_result=inject_last_result,
|
||||
)
|
||||
self.mode = mode
|
||||
self._requires_injection = True
|
||||
|
||||
def from_input(self, raw: str | bytes) -> Any:
|
||||
raise NotImplementedError
|
||||
@ -81,23 +76,23 @@ class BaseIOAction(BaseAction):
|
||||
def to_output(self, result: Any) -> str | bytes:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _resolve_input(self, kwargs: dict[str, Any]) -> str | bytes:
|
||||
last_result = kwargs.pop(self.inject_into, None)
|
||||
|
||||
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 last_result is not None:
|
||||
return last_result
|
||||
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.")
|
||||
return ""
|
||||
|
||||
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
|
||||
return None, None
|
||||
|
||||
async def __call__(self, *args, **kwargs):
|
||||
context = ExecutionContext(
|
||||
@ -117,8 +112,8 @@ class BaseIOAction(BaseAction):
|
||||
pass
|
||||
result = getattr(self, "_last_result", None)
|
||||
else:
|
||||
parsed_input = await self._resolve_input(kwargs)
|
||||
result = await self._run(parsed_input, *args, **kwargs)
|
||||
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
|
||||
@ -172,85 +167,3 @@ class BaseIOAction(BaseAction):
|
||||
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
|
||||
- Compatible with ChainedAction and Command.requires_input detection
|
||||
|
||||
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()
|
||||
|
||||
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:
|
||||
args = shlex.split(command)
|
||||
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})"
|
||||
)
|
||||
|
49
falyx/action/literal_input_action.py
Normal file
49
falyx/action/literal_input_action.py
Normal file
@ -0,0 +1,49 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""literal_input_action.py"""
|
||||
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})"
|
194
falyx/action/load_file_action.py
Normal file
194
falyx/action/load_file_action.py
Normal file
@ -0,0 +1,194 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""load_file_action.py"""
|
||||
import csv
|
||||
import json
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import toml
|
||||
import yaml
|
||||
from rich.tree import Tree
|
||||
|
||||
from falyx.action.action_types import FileType
|
||||
from falyx.action.base_action import BaseAction
|
||||
from falyx.context import ExecutionContext
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import HookType
|
||||
from falyx.logger import logger
|
||||
from falyx.themes import OneColors
|
||||
|
||||
|
||||
class LoadFileAction(BaseAction):
|
||||
"""LoadFileAction allows loading and parsing files of various types."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
file_path: str | Path | None = None,
|
||||
file_type: FileType | str = FileType.TEXT,
|
||||
inject_last_result: bool = False,
|
||||
inject_into: str = "file_path",
|
||||
):
|
||||
super().__init__(
|
||||
name=name, inject_last_result=inject_last_result, inject_into=inject_into
|
||||
)
|
||||
self._file_path = self._coerce_file_path(file_path)
|
||||
self._file_type = self._coerce_file_type(file_type)
|
||||
|
||||
@property
|
||||
def file_path(self) -> Path | None:
|
||||
"""Get the file path as a Path object."""
|
||||
return self._file_path
|
||||
|
||||
@file_path.setter
|
||||
def file_path(self, value: str | Path):
|
||||
"""Set the file path, converting to Path if necessary."""
|
||||
self._file_path = self._coerce_file_path(value)
|
||||
|
||||
def _coerce_file_path(self, file_path: str | Path | None) -> Path | None:
|
||||
"""Coerce the file path to a Path object."""
|
||||
if isinstance(file_path, Path):
|
||||
return file_path
|
||||
elif isinstance(file_path, str):
|
||||
return Path(file_path)
|
||||
elif file_path is None:
|
||||
return None
|
||||
else:
|
||||
raise TypeError("file_path must be a string or Path object")
|
||||
|
||||
@property
|
||||
def file_type(self) -> FileType:
|
||||
"""Get the file type."""
|
||||
return self._file_type
|
||||
|
||||
@file_type.setter
|
||||
def file_type(self, value: FileType | str):
|
||||
"""Set the file type, converting to FileType if necessary."""
|
||||
self._file_type = self._coerce_file_type(value)
|
||||
|
||||
def _coerce_file_type(self, file_type: FileType | str) -> FileType:
|
||||
"""Coerce the file type to a FileType enum."""
|
||||
if isinstance(file_type, FileType):
|
||||
return file_type
|
||||
elif isinstance(file_type, str):
|
||||
return FileType(file_type)
|
||||
else:
|
||||
raise TypeError("file_type must be a FileType enum or string")
|
||||
|
||||
def get_infer_target(self) -> tuple[None, None]:
|
||||
return None, None
|
||||
|
||||
async def load_file(self) -> Any:
|
||||
"""Load and parse the file based on its type."""
|
||||
if self.file_path is None:
|
||||
raise ValueError("file_path must be set before loading a file")
|
||||
elif not self.file_path.exists():
|
||||
raise FileNotFoundError(f"File not found: {self.file_path}")
|
||||
elif not self.file_path.is_file():
|
||||
raise ValueError(f"Path is not a regular file: {self.file_path}")
|
||||
value: Any = None
|
||||
try:
|
||||
if self.file_type == FileType.TEXT:
|
||||
value = self.file_path.read_text(encoding="UTF-8")
|
||||
elif self.file_type == FileType.PATH:
|
||||
value = self.file_path
|
||||
elif self.file_type == FileType.JSON:
|
||||
value = json.loads(self.file_path.read_text(encoding="UTF-8"))
|
||||
elif self.file_type == FileType.TOML:
|
||||
value = toml.loads(self.file_path.read_text(encoding="UTF-8"))
|
||||
elif self.file_type == FileType.YAML:
|
||||
value = yaml.safe_load(self.file_path.read_text(encoding="UTF-8"))
|
||||
elif self.file_type == FileType.CSV:
|
||||
with open(self.file_path, newline="", encoding="UTF-8") as csvfile:
|
||||
reader = csv.reader(csvfile)
|
||||
value = list(reader)
|
||||
elif self.file_type == FileType.TSV:
|
||||
with open(self.file_path, newline="", encoding="UTF-8") as tsvfile:
|
||||
reader = csv.reader(tsvfile, delimiter="\t")
|
||||
value = list(reader)
|
||||
elif self.file_type == FileType.XML:
|
||||
tree = ET.parse(self.file_path, parser=ET.XMLParser(encoding="UTF-8"))
|
||||
root = tree.getroot()
|
||||
value = ET.tostring(root, encoding="unicode")
|
||||
else:
|
||||
raise ValueError(f"Unsupported return type: {self.file_type}")
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Failed to parse %s: %s", self.file_path.name, error)
|
||||
return value
|
||||
|
||||
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)
|
||||
|
||||
if "file_path" in kwargs:
|
||||
self.file_path = kwargs["file_path"]
|
||||
elif self.inject_last_result and self.last_result:
|
||||
self.file_path = self.last_result
|
||||
|
||||
result = await self.load_file()
|
||||
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}]📄 LoadFileAction[/] '{self.name}'"
|
||||
tree = parent.add(label) if parent else Tree(label)
|
||||
|
||||
tree.add(f"[dim]Path:[/] {self.file_path}")
|
||||
tree.add(f"[dim]Type:[/] {self.file_type.name if self.file_type else 'None'}")
|
||||
if self.file_path is None:
|
||||
tree.add(f"[{OneColors.DARK_RED_b}]❌ File path is not set[/]")
|
||||
elif not self.file_path.exists():
|
||||
tree.add(f"[{OneColors.DARK_RED_b}]❌ File does not exist[/]")
|
||||
elif not self.file_path.is_file():
|
||||
tree.add(f"[{OneColors.LIGHT_YELLOW_b}]⚠️ Not a regular file[/]")
|
||||
else:
|
||||
try:
|
||||
stat = self.file_path.stat()
|
||||
tree.add(f"[dim]Size:[/] {stat.st_size:,} bytes")
|
||||
tree.add(
|
||||
f"[dim]Modified:[/] {datetime.fromtimestamp(stat.st_mtime):%Y-%m-%d %H:%M:%S}"
|
||||
)
|
||||
tree.add(
|
||||
f"[dim]Created:[/] {datetime.fromtimestamp(stat.st_ctime):%Y-%m-%d %H:%M:%S}"
|
||||
)
|
||||
if self.file_type == FileType.TEXT:
|
||||
preview_lines = self.file_path.read_text(
|
||||
encoding="UTF-8"
|
||||
).splitlines()[:10]
|
||||
content_tree = tree.add("[dim]Preview (first 10 lines):[/]")
|
||||
for line in preview_lines:
|
||||
content_tree.add(f"[dim]{line}[/]")
|
||||
elif self.file_type in {FileType.JSON, FileType.YAML, FileType.TOML}:
|
||||
raw = self.load_file()
|
||||
if raw is not None:
|
||||
preview_str = (
|
||||
json.dumps(raw, indent=2)
|
||||
if isinstance(raw, dict)
|
||||
else str(raw)
|
||||
)
|
||||
preview_lines = preview_str.splitlines()[:10]
|
||||
content_tree = tree.add("[dim]Parsed preview:[/]")
|
||||
for line in preview_lines:
|
||||
content_tree.add(f"[dim]{line}[/]")
|
||||
except Exception as e:
|
||||
tree.add(f"[{OneColors.DARK_RED_b}]❌ Error reading file:[/] {e}")
|
||||
|
||||
if not parent:
|
||||
self.console.print(tree)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"LoadFileAction(file_path={self.file_path}, file_type={self.file_type})"
|
@ -3,11 +3,10 @@
|
||||
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.action import BaseAction
|
||||
from falyx.action.base_action import BaseAction
|
||||
from falyx.context import ExecutionContext
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import HookType
|
||||
@ -33,7 +32,6 @@ class MenuAction(BaseAction):
|
||||
default_selection: str = "",
|
||||
inject_last_result: bool = False,
|
||||
inject_into: str = "last_result",
|
||||
console: Console | None = None,
|
||||
prompt_session: PromptSession | None = None,
|
||||
never_prompt: bool = False,
|
||||
include_reserved: bool = True,
|
||||
@ -51,7 +49,6 @@ class MenuAction(BaseAction):
|
||||
self.columns = columns
|
||||
self.prompt_message = prompt_message
|
||||
self.default_selection = default_selection
|
||||
self.console = console or Console(color_system="auto")
|
||||
self.prompt_session = prompt_session or PromptSession()
|
||||
self.include_reserved = include_reserved
|
||||
self.show_table = show_table
|
||||
@ -73,6 +70,9 @@ class MenuAction(BaseAction):
|
||||
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(
|
||||
@ -105,15 +105,18 @@ class MenuAction(BaseAction):
|
||||
key = effective_default
|
||||
if not self.never_prompt:
|
||||
table = self._build_table()
|
||||
key = await prompt_for_selection(
|
||||
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,
|
||||
)
|
||||
if isinstance(key_, str):
|
||||
key = key_
|
||||
else:
|
||||
assert False, "Unreachable, MenuAction only supports single selection"
|
||||
option = self.menu_options[key]
|
||||
result = await option.action(*args, **kwargs)
|
||||
context.result = result
|
||||
@ -121,10 +124,10 @@ class MenuAction(BaseAction):
|
||||
return result
|
||||
|
||||
except BackSignal:
|
||||
logger.debug("[%s][BackSignal] ← Returning to previous menu", self.name)
|
||||
logger.debug("[%s][BackSignal] <- Returning to previous menu", self.name)
|
||||
return None
|
||||
except QuitSignal:
|
||||
logger.debug("[%s][QuitSignal] ← Exiting application", self.name)
|
||||
logger.debug("[%s][QuitSignal] <- Exiting application", self.name)
|
||||
raise
|
||||
except Exception as error:
|
||||
context.exception = error
|
||||
|
130
falyx/action/process_action.py
Normal file
130
falyx/action/process_action.py
Normal file
@ -0,0 +1,130 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""process_action.py"""
|
||||
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_action 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})"
|
||||
)
|
169
falyx/action/process_pool_action.py
Normal file
169
falyx/action/process_pool_action.py
Normal file
@ -0,0 +1,169 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""process_pool_action.py"""
|
||||
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, Sequence
|
||||
|
||||
from rich.tree import Tree
|
||||
|
||||
from falyx.action.base_action import BaseAction
|
||||
from falyx.context import ExecutionContext, SharedContext
|
||||
from falyx.exceptions import EmptyPoolError
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import HookManager, HookType
|
||||
from falyx.logger import logger
|
||||
from falyx.parser.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: Sequence[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: Sequence[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:
|
||||
if not self.actions:
|
||||
raise EmptyPoolError(f"[{self.name}] No actions to execute.")
|
||||
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."
|
||||
)
|
||||
updated_kwargs = self._maybe_inject_last_result(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})"
|
||||
)
|
131
falyx/action/prompt_menu_action.py
Normal file
131
falyx/action/prompt_menu_action.py
Normal file
@ -0,0 +1,131 @@
|
||||
# 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.tree import Tree
|
||||
|
||||
from falyx.action.base_action import BaseAction
|
||||
from falyx.context import ExecutionContext
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import HookType
|
||||
from falyx.logger import logger
|
||||
from falyx.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",
|
||||
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
|
||||
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'})"
|
||||
)
|
247
falyx/action/save_file_action.py
Normal file
247
falyx/action/save_file_action.py
Normal file
@ -0,0 +1,247 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""save_file_action.py"""
|
||||
import csv
|
||||
import json
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import toml
|
||||
import yaml
|
||||
from rich.tree import Tree
|
||||
|
||||
from falyx.action.action_types import FileType
|
||||
from falyx.action.base_action import BaseAction
|
||||
from falyx.context import ExecutionContext
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import HookType
|
||||
from falyx.logger import logger
|
||||
from falyx.themes import OneColors
|
||||
|
||||
|
||||
class SaveFileAction(BaseAction):
|
||||
"""
|
||||
SaveFileAction saves data to a file in the specified format (e.g., TEXT, JSON, YAML).
|
||||
Supports overwrite control and integrates with chaining workflows via inject_last_result.
|
||||
|
||||
Supported types: TEXT, JSON, YAML, TOML, CSV, TSV, XML
|
||||
|
||||
If the file exists and overwrite is False, the action will raise a FileExistsError.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
file_path: str,
|
||||
file_type: FileType | str = FileType.TEXT,
|
||||
mode: Literal["w", "a"] = "w",
|
||||
data: Any = None,
|
||||
overwrite: bool = True,
|
||||
create_dirs: bool = True,
|
||||
inject_last_result: bool = False,
|
||||
inject_into: str = "data",
|
||||
):
|
||||
"""
|
||||
SaveFileAction allows saving data to a file.
|
||||
|
||||
Args:
|
||||
name (str): Name of the action.
|
||||
file_path (str | Path): Path to the file where data will be saved.
|
||||
file_type (FileType | str): Format to write to (e.g. TEXT, JSON, YAML).
|
||||
mode (Literal["w", "a"]): File mode (default: "w").
|
||||
data (Any): Data to be saved (if not using inject_last_result).
|
||||
overwrite (bool): Whether to overwrite the file if it exists.
|
||||
create_dirs (bool): Whether to create parent directories if they do not exist.
|
||||
inject_last_result (bool): Whether to inject result from previous action.
|
||||
inject_into (str): Kwarg name to inject the last result as.
|
||||
"""
|
||||
super().__init__(
|
||||
name=name, inject_last_result=inject_last_result, inject_into=inject_into
|
||||
)
|
||||
self._file_path = self._coerce_file_path(file_path)
|
||||
self._file_type = self._coerce_file_type(file_type)
|
||||
self.data = data
|
||||
self.overwrite = overwrite
|
||||
self.mode = mode
|
||||
self.create_dirs = create_dirs
|
||||
|
||||
@property
|
||||
def file_path(self) -> Path | None:
|
||||
"""Get the file path as a Path object."""
|
||||
return self._file_path
|
||||
|
||||
@file_path.setter
|
||||
def file_path(self, value: str | Path):
|
||||
"""Set the file path, converting to Path if necessary."""
|
||||
self._file_path = self._coerce_file_path(value)
|
||||
|
||||
def _coerce_file_path(self, file_path: str | Path | None) -> Path | None:
|
||||
"""Coerce the file path to a Path object."""
|
||||
if isinstance(file_path, Path):
|
||||
return file_path
|
||||
elif isinstance(file_path, str):
|
||||
return Path(file_path)
|
||||
elif file_path is None:
|
||||
return None
|
||||
else:
|
||||
raise TypeError("file_path must be a string or Path object")
|
||||
|
||||
@property
|
||||
def file_type(self) -> FileType:
|
||||
"""Get the file type."""
|
||||
return self._file_type
|
||||
|
||||
@file_type.setter
|
||||
def file_type(self, value: FileType | str):
|
||||
"""Set the file type, converting to FileType if necessary."""
|
||||
self._file_type = self._coerce_file_type(value)
|
||||
|
||||
def _coerce_file_type(self, file_type: FileType | str) -> FileType:
|
||||
"""Coerce the file type to a FileType enum."""
|
||||
if isinstance(file_type, FileType):
|
||||
return file_type
|
||||
elif isinstance(file_type, str):
|
||||
return FileType(file_type)
|
||||
else:
|
||||
raise TypeError("file_type must be a FileType enum or string")
|
||||
|
||||
def get_infer_target(self) -> tuple[None, None]:
|
||||
return None, None
|
||||
|
||||
def _dict_to_xml(self, data: dict, root: ET.Element) -> None:
|
||||
"""Convert a dictionary to XML format."""
|
||||
for key, value in data.items():
|
||||
if isinstance(value, dict):
|
||||
sub_element = ET.SubElement(root, key)
|
||||
self._dict_to_xml(value, sub_element)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
item_element = ET.SubElement(root, key)
|
||||
if isinstance(item, dict):
|
||||
self._dict_to_xml(item, item_element)
|
||||
else:
|
||||
item_element.text = str(item)
|
||||
else:
|
||||
element = ET.SubElement(root, key)
|
||||
element.text = str(value)
|
||||
|
||||
async def save_file(self, data: Any) -> None:
|
||||
"""Save data to the specified file in the desired format."""
|
||||
if self.file_path is None:
|
||||
raise ValueError("file_path must be set before saving a file")
|
||||
elif self.file_path.exists() and not self.overwrite:
|
||||
raise FileExistsError(f"File already exists: {self.file_path}")
|
||||
|
||||
if self.file_path.parent and not self.file_path.parent.exists():
|
||||
if self.create_dirs:
|
||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
raise FileNotFoundError(
|
||||
f"Directory does not exist: {self.file_path.parent}"
|
||||
)
|
||||
|
||||
try:
|
||||
if self.file_type == FileType.TEXT:
|
||||
self.file_path.write_text(data, encoding="UTF-8")
|
||||
elif self.file_type == FileType.JSON:
|
||||
self.file_path.write_text(json.dumps(data, indent=4), encoding="UTF-8")
|
||||
elif self.file_type == FileType.TOML:
|
||||
self.file_path.write_text(toml.dumps(data), encoding="UTF-8")
|
||||
elif self.file_type == FileType.YAML:
|
||||
self.file_path.write_text(yaml.dump(data), encoding="UTF-8")
|
||||
elif self.file_type == FileType.CSV:
|
||||
if not isinstance(data, list) or not all(
|
||||
isinstance(row, list) for row in data
|
||||
):
|
||||
raise ValueError(
|
||||
f"{self.file_type.name} file type requires a list of lists"
|
||||
)
|
||||
with open(
|
||||
self.file_path, mode=self.mode, newline="", encoding="UTF-8"
|
||||
) as csvfile:
|
||||
writer = csv.writer(csvfile)
|
||||
writer.writerows(data)
|
||||
elif self.file_type == FileType.TSV:
|
||||
if not isinstance(data, list) or not all(
|
||||
isinstance(row, list) for row in data
|
||||
):
|
||||
raise ValueError(
|
||||
f"{self.file_type.name} file type requires a list of lists"
|
||||
)
|
||||
with open(
|
||||
self.file_path, mode=self.mode, newline="", encoding="UTF-8"
|
||||
) as tsvfile:
|
||||
writer = csv.writer(tsvfile, delimiter="\t")
|
||||
writer.writerows(data)
|
||||
elif self.file_type == FileType.XML:
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("XML file type requires data to be a dictionary")
|
||||
root = ET.Element("root")
|
||||
self._dict_to_xml(data, root)
|
||||
tree = ET.ElementTree(root)
|
||||
tree.write(self.file_path, encoding="UTF-8", xml_declaration=True)
|
||||
else:
|
||||
raise ValueError(f"Unsupported file type: {self.file_type}")
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Failed to save %s: %s", self.file_path.name, error)
|
||||
raise
|
||||
|
||||
async def _run(self, *args, **kwargs):
|
||||
combined_kwargs = self._maybe_inject_last_result(kwargs)
|
||||
data = self.data or combined_kwargs.get(self.inject_into)
|
||||
|
||||
context = ExecutionContext(
|
||||
name=self.name, args=args, kwargs=combined_kwargs, action=self
|
||||
)
|
||||
context.start_timer()
|
||||
|
||||
try:
|
||||
await self.hooks.trigger(HookType.BEFORE, context)
|
||||
|
||||
await self.save_file(data)
|
||||
logger.debug("File saved successfully: %s", self.file_path)
|
||||
|
||||
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||
return str(self.file_path)
|
||||
|
||||
except Exception as error:
|
||||
context.exception = error
|
||||
await self.hooks.trigger(HookType.ON_ERROR, context)
|
||||
raise
|
||||
finally:
|
||||
context.stop_timer()
|
||||
await self.hooks.trigger(HookType.AFTER, context)
|
||||
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
||||
er.record(context)
|
||||
|
||||
async def preview(self, parent: Tree | None = None):
|
||||
label = f"[{OneColors.CYAN}]💾 SaveFileAction[/] '{self.name}'"
|
||||
tree = parent.add(label) if parent else Tree(label)
|
||||
|
||||
tree.add(f"[dim]Path:[/] {self.file_path}")
|
||||
tree.add(f"[dim]Type:[/] {self.file_type.name}")
|
||||
tree.add(f"[dim]Overwrite:[/] {self.overwrite}")
|
||||
|
||||
if self.file_path and self.file_path.exists():
|
||||
if self.overwrite:
|
||||
tree.add(f"[{OneColors.LIGHT_YELLOW}]⚠️ File will be overwritten[/]")
|
||||
else:
|
||||
tree.add(
|
||||
f"[{OneColors.DARK_RED}]❌ File exists and overwrite is disabled[/]"
|
||||
)
|
||||
stat = self.file_path.stat()
|
||||
tree.add(f"[dim]Size:[/] {stat.st_size:,} bytes")
|
||||
tree.add(
|
||||
f"[dim]Modified:[/] {datetime.fromtimestamp(stat.st_mtime):%Y-%m-%d %H:%M:%S}"
|
||||
)
|
||||
tree.add(
|
||||
f"[dim]Created:[/] {datetime.fromtimestamp(stat.st_ctime):%Y-%m-%d %H:%M:%S}"
|
||||
)
|
||||
|
||||
if not parent:
|
||||
self.console.print(tree)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"SaveFileAction(file_path={self.file_path}, file_type={self.file_type})"
|
@ -11,11 +11,10 @@ from typing import Any
|
||||
import toml
|
||||
import yaml
|
||||
from prompt_toolkit import PromptSession
|
||||
from rich.console import Console
|
||||
from rich.tree import Tree
|
||||
|
||||
from falyx.action.action import BaseAction
|
||||
from falyx.action.types import FileReturnType
|
||||
from falyx.action.action_types import FileType
|
||||
from falyx.action.base_action import BaseAction
|
||||
from falyx.context import ExecutionContext
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import HookType
|
||||
@ -25,6 +24,7 @@ from falyx.selection import (
|
||||
prompt_for_selection,
|
||||
render_selection_dict_table,
|
||||
)
|
||||
from falyx.signals import CancelSignal
|
||||
from falyx.themes import OneColors
|
||||
|
||||
|
||||
@ -49,8 +49,7 @@ class SelectFileAction(BaseAction):
|
||||
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.
|
||||
return_type (FileType): What to return (path, content, parsed).
|
||||
prompt_session (PromptSession | None): Prompt session for user input.
|
||||
"""
|
||||
|
||||
@ -64,8 +63,10 @@ class SelectFileAction(BaseAction):
|
||||
prompt_message: str = "Choose > ",
|
||||
style: str = OneColors.WHITE,
|
||||
suffix_filter: str | None = None,
|
||||
return_type: FileReturnType | str = FileReturnType.PATH,
|
||||
console: Console | None = None,
|
||||
return_type: FileType | str = FileType.PATH,
|
||||
number_selections: int | str = 1,
|
||||
separator: str = ",",
|
||||
allow_duplicates: bool = False,
|
||||
prompt_session: PromptSession | None = None,
|
||||
):
|
||||
super().__init__(name)
|
||||
@ -75,39 +76,59 @@ class SelectFileAction(BaseAction):
|
||||
self.prompt_message = prompt_message
|
||||
self.suffix_filter = suffix_filter
|
||||
self.style = style
|
||||
self.console = console or Console(color_system="auto")
|
||||
self.number_selections = number_selections
|
||||
self.separator = separator
|
||||
self.allow_duplicates = allow_duplicates
|
||||
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):
|
||||
@property
|
||||
def number_selections(self) -> int | str:
|
||||
return self._number_selections
|
||||
|
||||
@number_selections.setter
|
||||
def number_selections(self, value: int | str):
|
||||
if isinstance(value, int) and value > 0:
|
||||
self._number_selections: int | str = value
|
||||
elif isinstance(value, str):
|
||||
if value not in ("*"):
|
||||
raise ValueError("number_selections string must be one of '*'")
|
||||
self._number_selections = value
|
||||
else:
|
||||
raise ValueError("number_selections must be a positive integer or one of '*'")
|
||||
|
||||
def _coerce_return_type(self, return_type: FileType | str) -> FileType:
|
||||
if isinstance(return_type, FileType):
|
||||
return return_type
|
||||
return FileReturnType(return_type)
|
||||
elif isinstance(return_type, str):
|
||||
return FileType(return_type)
|
||||
else:
|
||||
raise TypeError("return_type must be a FileType enum or string")
|
||||
|
||||
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:
|
||||
if self.return_type == FileType.TEXT:
|
||||
value = file.read_text(encoding="UTF-8")
|
||||
elif self.return_type == FileReturnType.PATH:
|
||||
elif self.return_type == FileType.PATH:
|
||||
value = file
|
||||
elif self.return_type == FileReturnType.JSON:
|
||||
elif self.return_type == FileType.JSON:
|
||||
value = json.loads(file.read_text(encoding="UTF-8"))
|
||||
elif self.return_type == FileReturnType.TOML:
|
||||
elif self.return_type == FileType.TOML:
|
||||
value = toml.loads(file.read_text(encoding="UTF-8"))
|
||||
elif self.return_type == FileReturnType.YAML:
|
||||
elif self.return_type == FileType.YAML:
|
||||
value = yaml.safe_load(file.read_text(encoding="UTF-8"))
|
||||
elif self.return_type == FileReturnType.CSV:
|
||||
elif self.return_type == FileType.CSV:
|
||||
with open(file, newline="", encoding="UTF-8") as csvfile:
|
||||
reader = csv.reader(csvfile)
|
||||
value = list(reader)
|
||||
elif self.return_type == FileReturnType.TSV:
|
||||
elif self.return_type == FileType.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:
|
||||
elif self.return_type == FileType.XML:
|
||||
tree = ET.parse(file, parser=ET.XMLParser(encoding="UTF-8"))
|
||||
root = tree.getroot()
|
||||
value = ET.tostring(root, encoding="unicode")
|
||||
@ -118,39 +139,70 @@ class SelectFileAction(BaseAction):
|
||||
description=file.name, value=value, style=self.style
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning("[ERROR] Failed to parse %s: %s", file.name, 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)
|
||||
|
||||
if not self.directory.exists():
|
||||
raise FileNotFoundError(f"Directory {self.directory} does not exist.")
|
||||
elif not self.directory.is_dir():
|
||||
raise NotADirectoryError(f"{self.directory} is not a directory.")
|
||||
|
||||
files = [
|
||||
f
|
||||
for f in self.directory.iterdir()
|
||||
if f.is_file()
|
||||
and (self.suffix_filter is None or f.suffix == self.suffix_filter)
|
||||
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, columns=self.columns
|
||||
title=self.title, selections=options | cancel_option, columns=self.columns
|
||||
)
|
||||
|
||||
key = await prompt_for_selection(
|
||||
options.keys(),
|
||||
keys = await prompt_for_selection(
|
||||
(options | cancel_option).keys(),
|
||||
table,
|
||||
console=self.console,
|
||||
prompt_session=self.prompt_session,
|
||||
prompt_message=self.prompt_message,
|
||||
number_selections=self.number_selections,
|
||||
separator=self.separator,
|
||||
allow_duplicates=self.allow_duplicates,
|
||||
cancel_key=cancel_key,
|
||||
)
|
||||
|
||||
result = options[key].value
|
||||
if isinstance(keys, str):
|
||||
if keys == cancel_key:
|
||||
raise CancelSignal("User canceled the selection.")
|
||||
result = options[keys].value
|
||||
elif isinstance(keys, list):
|
||||
result = [options[key].value for key in keys]
|
||||
|
||||
context.result = result
|
||||
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||
return result
|
||||
@ -176,11 +228,11 @@ class SelectFileAction(BaseAction):
|
||||
try:
|
||||
files = list(self.directory.iterdir())
|
||||
if self.suffix_filter:
|
||||
files = [f for f in files if f.suffix == 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 f in sample:
|
||||
file_list.add(f"[dim]{f.name}[/]")
|
||||
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:
|
||||
|
@ -3,23 +3,24 @@
|
||||
from typing import Any
|
||||
|
||||
from prompt_toolkit import PromptSession
|
||||
from rich.console import Console
|
||||
from rich.tree import Tree
|
||||
|
||||
from falyx.action.action import BaseAction
|
||||
from falyx.action.action_types import SelectionReturnType
|
||||
from falyx.action.base_action import BaseAction
|
||||
from falyx.context import ExecutionContext
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import HookType
|
||||
from falyx.logger import logger
|
||||
from falyx.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
|
||||
from falyx.utils import CaseInsensitiveDict
|
||||
|
||||
|
||||
class SelectionAction(BaseAction):
|
||||
@ -34,16 +35,24 @@ class SelectionAction(BaseAction):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
selections: list[str] | set[str] | tuple[str, ...] | dict[str, SelectionOption],
|
||||
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 = "",
|
||||
number_selections: int | str = 1,
|
||||
separator: str = ",",
|
||||
allow_duplicates: bool = False,
|
||||
inject_last_result: bool = False,
|
||||
inject_into: str = "last_result",
|
||||
return_key: bool = False,
|
||||
console: Console | None = None,
|
||||
return_type: SelectionReturnType | str = "value",
|
||||
prompt_session: PromptSession | None = None,
|
||||
never_prompt: bool = False,
|
||||
show_table: bool = True,
|
||||
@ -55,18 +64,42 @@ class SelectionAction(BaseAction):
|
||||
never_prompt=never_prompt,
|
||||
)
|
||||
# Setter normalizes to correct type, mypy can't infer that
|
||||
self.selections: list[str] | CaseInsensitiveDict = selections # type: ignore[assignment]
|
||||
self.return_key = return_key
|
||||
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
|
||||
self.console = console or Console(color_system="auto")
|
||||
self.prompt_session = prompt_session or PromptSession()
|
||||
self.default_selection = default_selection
|
||||
self.number_selections = number_selections
|
||||
self.separator = separator
|
||||
self.allow_duplicates = allow_duplicates
|
||||
self.prompt_message = prompt_message
|
||||
self.show_table = show_table
|
||||
|
||||
@property
|
||||
def selections(self) -> list[str] | CaseInsensitiveDict:
|
||||
def number_selections(self) -> int | str:
|
||||
return self._number_selections
|
||||
|
||||
@number_selections.setter
|
||||
def number_selections(self, value: int | str):
|
||||
if isinstance(value, int) and value > 0:
|
||||
self._number_selections: int | str = value
|
||||
elif isinstance(value, str):
|
||||
if value not in ("*"):
|
||||
raise ValueError("number_selections string must be '*'")
|
||||
self._number_selections = value
|
||||
else:
|
||||
raise ValueError("number_selections must be a positive integer or '*'")
|
||||
|
||||
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
|
||||
@ -74,17 +107,101 @@ class SelectionAction(BaseAction):
|
||||
self, value: list[str] | set[str] | tuple[str, ...] | dict[str, SelectionOption]
|
||||
):
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
self._selections: list[str] | CaseInsensitiveDict = list(value)
|
||||
self._selections: list[str] | SelectionOptionMap = list(value)
|
||||
elif isinstance(value, dict):
|
||||
cid = CaseInsensitiveDict()
|
||||
cid.update(value)
|
||||
self._selections = cid
|
||||
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
|
||||
|
||||
def _get_result_from_keys(self, keys: str | list[str]) -> Any:
|
||||
if not isinstance(self.selections, dict):
|
||||
raise TypeError("Selections must be a dictionary to get result by keys.")
|
||||
if self.return_type == SelectionReturnType.KEY:
|
||||
result: Any = keys
|
||||
elif self.return_type == SelectionReturnType.VALUE:
|
||||
if isinstance(keys, list):
|
||||
result = [self.selections[key].value for key in keys]
|
||||
elif isinstance(keys, str):
|
||||
result = self.selections[keys].value
|
||||
elif self.return_type == SelectionReturnType.ITEMS:
|
||||
if isinstance(keys, list):
|
||||
result = {key: self.selections[key] for key in keys}
|
||||
elif isinstance(keys, str):
|
||||
result = {keys: self.selections[keys]}
|
||||
elif self.return_type == SelectionReturnType.DESCRIPTION:
|
||||
if isinstance(keys, list):
|
||||
result = [self.selections[key].description for key in keys]
|
||||
elif isinstance(keys, str):
|
||||
result = self.selections[keys].description
|
||||
elif self.return_type == SelectionReturnType.DESCRIPTION_VALUE:
|
||||
if isinstance(keys, list):
|
||||
result = {
|
||||
self.selections[key].description: self.selections[key].value
|
||||
for key in keys
|
||||
}
|
||||
elif isinstance(keys, str):
|
||||
result = {self.selections[keys].description: self.selections[keys].value}
|
||||
else:
|
||||
raise ValueError(f"Unsupported return type: {self.return_type}")
|
||||
return result
|
||||
|
||||
async def _run(self, *args, **kwargs) -> Any:
|
||||
kwargs = self._maybe_inject_last_result(kwargs)
|
||||
context = ExecutionContext(
|
||||
@ -120,51 +237,85 @@ class SelectionAction(BaseAction):
|
||||
if self.never_prompt and not effective_default:
|
||||
raise ValueError(
|
||||
f"[{self.name}] 'never_prompt' is True but no valid default_selection "
|
||||
"was provided."
|
||||
"or usable last_result was available."
|
||||
)
|
||||
|
||||
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,
|
||||
selections=self.selections + ["Cancel"],
|
||||
columns=self.columns,
|
||||
formatter=self.cancel_formatter,
|
||||
)
|
||||
if not self.never_prompt:
|
||||
indices: int | list[int] = await prompt_for_index(
|
||||
len(self.selections),
|
||||
table,
|
||||
default_selection=effective_default,
|
||||
prompt_session=self.prompt_session,
|
||||
prompt_message=self.prompt_message,
|
||||
show_table=self.show_table,
|
||||
number_selections=self.number_selections,
|
||||
separator=self.separator,
|
||||
allow_duplicates=self.allow_duplicates,
|
||||
cancel_key=self.cancel_key,
|
||||
)
|
||||
else:
|
||||
if effective_default:
|
||||
indices = int(effective_default)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"[{self.name}] 'never_prompt' is True but no valid "
|
||||
"default_selection was provided."
|
||||
)
|
||||
|
||||
if indices == int(self.cancel_key):
|
||||
raise CancelSignal("User cancelled the selection.")
|
||||
if isinstance(indices, list):
|
||||
result: str | list[str] = [
|
||||
self.selections[index] for index in indices
|
||||
]
|
||||
elif isinstance(indices, int):
|
||||
result = self.selections[indices]
|
||||
else:
|
||||
assert False, "unreachable"
|
||||
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:
|
||||
index = await prompt_for_index(
|
||||
len(self.selections) - 1,
|
||||
keys = 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,
|
||||
number_selections=self.number_selections,
|
||||
separator=self.separator,
|
||||
allow_duplicates=self.allow_duplicates,
|
||||
cancel_key=self.cancel_key,
|
||||
)
|
||||
else:
|
||||
index = effective_default
|
||||
result = self.selections[int(index)]
|
||||
elif isinstance(self.selections, dict):
|
||||
table = render_selection_dict_table(
|
||||
title=self.title, selections=self.selections, columns=self.columns
|
||||
)
|
||||
if not self.never_prompt:
|
||||
key = await prompt_for_selection(
|
||||
self.selections.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
|
||||
result = key if self.return_key else self.selections[key].value
|
||||
keys = effective_default
|
||||
if keys == self.cancel_key:
|
||||
raise CancelSignal("User cancelled the selection.")
|
||||
|
||||
result = self._get_result_from_keys(keys)
|
||||
else:
|
||||
raise TypeError(
|
||||
"'selections' must be a list[str] or dict[str, tuple[str, Any]], "
|
||||
"'selections' must be a list[str] or dict[str, Any], "
|
||||
f"got {type(self.selections).__name__}"
|
||||
)
|
||||
context.result = result
|
||||
@ -203,7 +354,7 @@ class SelectionAction(BaseAction):
|
||||
return
|
||||
|
||||
tree.add(f"[dim]Default:[/] '{self.default_selection or self.last_result}'")
|
||||
tree.add(f"[dim]Return:[/] {'Key' if self.return_key else 'Value'}")
|
||||
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:
|
||||
@ -218,6 +369,6 @@ class SelectionAction(BaseAction):
|
||||
return (
|
||||
f"SelectionAction(name={self.name!r}, type={selection_type}, "
|
||||
f"default_selection={self.default_selection!r}, "
|
||||
f"return_key={self.return_key}, "
|
||||
f"return_type={self.return_type!r}, "
|
||||
f"prompt={'off' if self.never_prompt else 'on'})"
|
||||
)
|
||||
|
105
falyx/action/shell_action.py
Normal file
105
falyx/action/shell_action.py
Normal file
@ -0,0 +1,105 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""shell_action.py
|
||||
Execute shell commands with input substitution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, Callable
|
||||
|
||||
from rich.tree import Tree
|
||||
|
||||
from falyx.action.io_action import BaseIOAction
|
||||
from falyx.exceptions import FalyxError
|
||||
from falyx.themes import OneColors
|
||||
|
||||
|
||||
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})"
|
||||
)
|
@ -14,7 +14,7 @@ class SignalAction(Action):
|
||||
Useful for exiting a menu, going back, or halting execution gracefully.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, signal: Exception):
|
||||
def __init__(self, name: str, signal: FlowSignal):
|
||||
self.signal = signal
|
||||
super().__init__(name, action=self.raise_signal)
|
||||
|
||||
|
@ -1,37 +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}")
|
@ -1,9 +1,10 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""user_input_action.py"""
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.validation import Validator
|
||||
from rich.console import Console
|
||||
from rich.tree import Tree
|
||||
|
||||
from falyx.action import BaseAction
|
||||
from falyx.action.base_action import BaseAction
|
||||
from falyx.context import ExecutionContext
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import HookType
|
||||
@ -18,7 +19,6 @@ class UserInputAction(BaseAction):
|
||||
name (str): Action name.
|
||||
prompt_text (str): Prompt text (can include '{last_result}' for interpolation).
|
||||
validator (Validator, optional): Prompt Toolkit validator.
|
||||
console (Console, optional): Rich console for rendering.
|
||||
prompt_session (PromptSession, optional): Reusable prompt session.
|
||||
inject_last_result (bool): Whether to inject last_result into prompt.
|
||||
inject_into (str): Key to use for injection (default: 'last_result').
|
||||
@ -29,8 +29,8 @@ class UserInputAction(BaseAction):
|
||||
name: str,
|
||||
*,
|
||||
prompt_text: str = "Input > ",
|
||||
default_text: str = "",
|
||||
validator: Validator | None = None,
|
||||
console: Console | None = None,
|
||||
prompt_session: PromptSession | None = None,
|
||||
inject_last_result: bool = False,
|
||||
):
|
||||
@ -40,8 +40,11 @@ class UserInputAction(BaseAction):
|
||||
)
|
||||
self.prompt_text = prompt_text
|
||||
self.validator = validator
|
||||
self.console = console or Console(color_system="auto")
|
||||
self.prompt_session = prompt_session or PromptSession()
|
||||
self.default_text = default_text
|
||||
|
||||
def get_infer_target(self) -> tuple[None, None]:
|
||||
return None, None
|
||||
|
||||
async def _run(self, *args, **kwargs) -> str:
|
||||
context = ExecutionContext(
|
||||
@ -61,6 +64,7 @@ class UserInputAction(BaseAction):
|
||||
answer = await self.prompt_session.prompt_async(
|
||||
prompt_text,
|
||||
validator=self.validator,
|
||||
default=kwargs.get("default_text", self.default_text),
|
||||
)
|
||||
context.result = answer
|
||||
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||
|
@ -1,596 +0,0 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Iterable
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from falyx.exceptions import CommandArgumentError
|
||||
from falyx.signals import HelpSignal
|
||||
|
||||
|
||||
class ArgumentAction(Enum):
|
||||
"""Defines the action to be taken when the argument is encountered."""
|
||||
|
||||
STORE = "store"
|
||||
STORE_TRUE = "store_true"
|
||||
STORE_FALSE = "store_false"
|
||||
APPEND = "append"
|
||||
EXTEND = "extend"
|
||||
COUNT = "count"
|
||||
HELP = "help"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Argument:
|
||||
"""Represents a command-line argument."""
|
||||
|
||||
flags: list[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 = 1 # int, '?', '*', '+'
|
||||
positional: bool = False # True if no leading - or -- in flags
|
||||
|
||||
|
||||
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) -> None:
|
||||
"""Initialize the CommandArgumentParser."""
|
||||
self.command_description: str = ""
|
||||
self._arguments: list[Argument] = []
|
||||
self._flag_map: dict[str, Argument] = {}
|
||||
self._dest_set: set[str] = set()
|
||||
self._add_help()
|
||||
self.console = Console(color_system="auto")
|
||||
|
||||
def _add_help(self):
|
||||
"""Add help argument to the parser."""
|
||||
self.add_argument(
|
||||
"--help",
|
||||
"-h",
|
||||
action=ArgumentAction.HELP,
|
||||
help="Show this help message and exit.",
|
||||
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 | None:
|
||||
"""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
|
||||
) -> bool:
|
||||
"""Determine if the argument is required."""
|
||||
if required:
|
||||
return True
|
||||
if positional:
|
||||
if isinstance(nargs, int):
|
||||
return nargs > 0
|
||||
elif isinstance(nargs, str):
|
||||
if nargs in ("+"):
|
||||
return True
|
||||
elif nargs in ("*", "?"):
|
||||
return False
|
||||
else:
|
||||
raise CommandArgumentError(f"Invalid nargs value: {nargs}")
|
||||
|
||||
return required
|
||||
|
||||
def _validate_nargs(self, nargs: int | str) -> int | str:
|
||||
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, 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:
|
||||
expected_type(choice)
|
||||
except Exception:
|
||||
raise CommandArgumentError(
|
||||
f"Invalid choice {choice!r}: not coercible to {expected_type.__name__}"
|
||||
)
|
||||
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:
|
||||
expected_type(default)
|
||||
except Exception:
|
||||
raise CommandArgumentError(
|
||||
f"Default value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__}"
|
||||
)
|
||||
|
||||
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:
|
||||
expected_type(item)
|
||||
except Exception:
|
||||
raise CommandArgumentError(
|
||||
f"Default list value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__}"
|
||||
)
|
||||
|
||||
def _resolve_default(
|
||||
self, action: ArgumentAction, default: Any, nargs: str | int
|
||||
) -> 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 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, **kwargs):
|
||||
"""Add an argument to the parser.
|
||||
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().
|
||||
"""
|
||||
self._validate_flags(flags)
|
||||
positional = self._is_positional(flags)
|
||||
dest = self._get_dest_from_flags(flags, kwargs.get("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."
|
||||
)
|
||||
self._dest_set.add(dest)
|
||||
action = kwargs.get("action", ArgumentAction.STORE)
|
||||
if not isinstance(action, ArgumentAction):
|
||||
try:
|
||||
action = ArgumentAction(action)
|
||||
except ValueError:
|
||||
raise CommandArgumentError(
|
||||
f"Invalid action '{action}' is not a valid ArgumentAction"
|
||||
)
|
||||
flags = list(flags)
|
||||
nargs = self._validate_nargs(kwargs.get("nargs", 1))
|
||||
default = self._resolve_default(action, kwargs.get("default"), nargs)
|
||||
expected_type = kwargs.get("type", str)
|
||||
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(kwargs.get("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(
|
||||
kwargs.get("required", False), positional, nargs
|
||||
)
|
||||
argument = Argument(
|
||||
flags=flags,
|
||||
dest=dest,
|
||||
action=action,
|
||||
type=expected_type,
|
||||
default=default,
|
||||
choices=choices,
|
||||
required=required,
|
||||
help=kwargs.get("help", ""),
|
||||
nargs=nargs,
|
||||
positional=positional,
|
||||
)
|
||||
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}'"
|
||||
)
|
||||
self._flag_map[flag] = argument
|
||||
self._arguments.append(argument)
|
||||
|
||||
def get_argument(self, dest: str) -> Argument | None:
|
||||
return next((a for a in self._arguments if a.dest == dest), None)
|
||||
|
||||
def _consume_nargs(
|
||||
self, args: list[str], start: int, spec: Argument
|
||||
) -> tuple[list[str], int]:
|
||||
values = []
|
||||
i = start
|
||||
if isinstance(spec.nargs, int):
|
||||
# assert i + spec.nargs <= len(
|
||||
# args
|
||||
# ), "Not enough arguments provided: shouldn't happen"
|
||||
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
|
||||
else:
|
||||
assert False, "Invalid nargs value: shouldn't happen"
|
||||
|
||||
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 :]:
|
||||
if 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
|
||||
else:
|
||||
assert False, "Invalid nargs value: shouldn't happen"
|
||||
|
||||
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 = [spec.type(v) for v in values]
|
||||
except Exception:
|
||||
raise CommandArgumentError(
|
||||
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
|
||||
)
|
||||
|
||||
if spec.action == ArgumentAction.APPEND:
|
||||
assert result.get(spec.dest) is not None, "dest should not be None"
|
||||
if spec.nargs in (None, 1):
|
||||
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):
|
||||
raise CommandArgumentError(f"Unexpected positional argument: {args[i:]}")
|
||||
|
||||
return i
|
||||
|
||||
def parse_args(self, args: list[str] | None = None) -> dict[str, Any]:
|
||||
"""Parse Falyx Command arguments."""
|
||||
if args is None:
|
||||
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._flag_map:
|
||||
spec = self._flag_map[token]
|
||||
action = spec.action
|
||||
|
||||
if action == ArgumentAction.HELP:
|
||||
self.render_help()
|
||||
raise HelpSignal()
|
||||
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 = [spec.type(value) for value in values]
|
||||
except ValueError:
|
||||
raise CommandArgumentError(
|
||||
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
|
||||
)
|
||||
if spec.nargs in (None, 1):
|
||||
try:
|
||||
result[spec.dest].append(spec.type(values[0]))
|
||||
except ValueError:
|
||||
raise CommandArgumentError(
|
||||
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
|
||||
)
|
||||
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 = [spec.type(value) for value in values]
|
||||
except ValueError:
|
||||
raise CommandArgumentError(
|
||||
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
|
||||
)
|
||||
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 = [spec.type(v) for v in values]
|
||||
except ValueError:
|
||||
raise CommandArgumentError(
|
||||
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
|
||||
)
|
||||
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
|
||||
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 = 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 isinstance(spec.nargs, int) and spec.nargs > 1:
|
||||
if not isinstance(result.get(spec.dest), list):
|
||||
raise CommandArgumentError(
|
||||
f"Invalid value for {spec.dest}: expected a list"
|
||||
)
|
||||
if spec.action == ArgumentAction.APPEND:
|
||||
if not isinstance(result[spec.dest], list):
|
||||
raise CommandArgumentError(
|
||||
f"Invalid value for {spec.dest}: expected a list"
|
||||
)
|
||||
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 not isinstance(result[spec.dest], list):
|
||||
raise CommandArgumentError(
|
||||
f"Invalid value for {spec.dest}: expected a list"
|
||||
)
|
||||
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
|
||||
|
||||
def parse_args_split(self, args: list[str]) -> tuple[tuple[Any, ...], dict[str, Any]]:
|
||||
"""
|
||||
Returns:
|
||||
tuple[args, kwargs] - Positional arguments in defined order,
|
||||
followed by keyword argument mapping.
|
||||
"""
|
||||
parsed = self.parse_args(args)
|
||||
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 render_help(self):
|
||||
table = Table(title=f"{self.command_description} Help")
|
||||
table.add_column("Flags")
|
||||
table.add_column("Help")
|
||||
for arg in self._arguments:
|
||||
if arg.dest == "help":
|
||||
continue
|
||||
flag_str = ", ".join(arg.flags) if not arg.positional else arg.dest
|
||||
table.add_row(flag_str, arg.help or "")
|
||||
table.add_section()
|
||||
arg = self.get_argument("help")
|
||||
flag_str = ", ".join(arg.flags) if not arg.positional else arg.dest
|
||||
table.add_row(flag_str, arg.help or "")
|
||||
self.console.print(table)
|
||||
|
||||
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)}, dests={len(self._dest_set)}, "
|
||||
f"required={required}, positional={positional})"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return str(self)
|
@ -7,6 +7,7 @@ from prompt_toolkit.formatted_text import HTML, merge_formatted_text
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
from rich.console import Console
|
||||
|
||||
from falyx.console import console
|
||||
from falyx.options_manager import OptionsManager
|
||||
from falyx.themes import OneColors
|
||||
from falyx.utils import CaseInsensitiveDict, chunks
|
||||
@ -30,7 +31,7 @@ class BottomBar:
|
||||
key_validator: Callable[[str], bool] | None = None,
|
||||
) -> None:
|
||||
self.columns = columns
|
||||
self.console = Console(color_system="auto")
|
||||
self.console: Console = console
|
||||
self._named_items: dict[str, Callable[[], HTML]] = {}
|
||||
self._value_getters: dict[str, Callable[[], Any]] = CaseInsensitiveDict()
|
||||
self.toggle_keys: list[str] = []
|
||||
|
153
falyx/command.py
153
falyx/command.py
@ -19,23 +19,23 @@ in building robust interactive menus.
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
from functools import cached_property
|
||||
from typing import Any, Callable
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
|
||||
from rich.console import Console
|
||||
from rich.tree import Tree
|
||||
|
||||
from falyx.action.action import Action, ActionGroup, BaseAction, ChainedAction
|
||||
from falyx.action.io_action import BaseIOAction
|
||||
from falyx.argparse import CommandArgumentParser
|
||||
from falyx.action.action import Action
|
||||
from falyx.action.base_action import BaseAction
|
||||
from falyx.console import console
|
||||
from falyx.context import ExecutionContext
|
||||
from falyx.debug import register_debug_hooks
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import HookManager, HookType
|
||||
from falyx.logger import logger
|
||||
from falyx.options_manager import OptionsManager
|
||||
from falyx.parser.command_argument_parser import CommandArgumentParser
|
||||
from falyx.parser.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
|
||||
@ -44,8 +44,6 @@ from falyx.signals import CancelSignal
|
||||
from falyx.themes import OneColors
|
||||
from falyx.utils import ensure_async
|
||||
|
||||
console = Console(color_system="auto")
|
||||
|
||||
|
||||
class Command(BaseModel):
|
||||
"""
|
||||
@ -89,7 +87,17 @@ class Command(BaseModel):
|
||||
retry_policy (RetryPolicy): Retry behavior configuration.
|
||||
tags (list[str]): Organizational tags for the command.
|
||||
logging_hooks (bool): Whether to attach logging hooks automatically.
|
||||
requires_input (bool | None): Indicates if the action needs input.
|
||||
options_manager (OptionsManager): Manages global command-line options.
|
||||
arg_parser (CommandArgumentParser): Parses command arguments.
|
||||
arguments (list[dict[str, Any]]): Argument definitions for the command.
|
||||
argument_config (Callable[[CommandArgumentParser], None] | None): Function to configure arguments
|
||||
for the command parser.
|
||||
arg_metadata (dict[str, str | dict[str, Any]]): Metadata for arguments,
|
||||
such as help text or choices.
|
||||
simple_help_signature (bool): Whether to use a simplified help signature.
|
||||
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.
|
||||
@ -101,12 +109,13 @@ class Command(BaseModel):
|
||||
|
||||
key: str
|
||||
description: str
|
||||
action: BaseAction | Callable[[], Any]
|
||||
action: BaseAction | Callable[..., Any] | Callable[..., Awaitable[Any]]
|
||||
args: tuple = ()
|
||||
kwargs: dict[str, Any] = Field(default_factory=dict)
|
||||
hidden: bool = False
|
||||
aliases: list[str] = Field(default_factory=list)
|
||||
help_text: str = ""
|
||||
help_epilog: str = ""
|
||||
style: str = OneColors.WHITE
|
||||
confirm: bool = False
|
||||
confirm_message: str = "Are you sure?"
|
||||
@ -122,25 +131,55 @@ class Command(BaseModel):
|
||||
retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
logging_hooks: bool = False
|
||||
requires_input: bool | None = None
|
||||
options_manager: OptionsManager = Field(default_factory=OptionsManager)
|
||||
arg_parser: CommandArgumentParser = Field(default_factory=CommandArgumentParser)
|
||||
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)
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
def parse_args(self, raw_args: list[str] | str) -> tuple[tuple, dict]:
|
||||
if self.custom_parser:
|
||||
async def parse_args(
|
||||
self, raw_args: list[str] | str, from_validate: bool = False
|
||||
) -> tuple[tuple, dict]:
|
||||
if callable(self.custom_parser):
|
||||
if isinstance(raw_args, str):
|
||||
raw_args = shlex.split(raw_args)
|
||||
try:
|
||||
raw_args = shlex.split(raw_args)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"[Command:%s] Failed to split arguments: %s",
|
||||
self.key,
|
||||
raw_args,
|
||||
)
|
||||
return ((), {})
|
||||
return self.custom_parser(raw_args)
|
||||
|
||||
if isinstance(raw_args, str):
|
||||
raw_args = shlex.split(raw_args)
|
||||
return self.arg_parser.parse_args_split(raw_args)
|
||||
try:
|
||||
raw_args = shlex.split(raw_args)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"[Command:%s] Failed to split arguments: %s",
|
||||
self.key,
|
||||
raw_args,
|
||||
)
|
||||
return ((), {})
|
||||
if not isinstance(self.arg_parser, CommandArgumentParser):
|
||||
logger.warning(
|
||||
"[Command:%s] No argument parser configured, using default parsing.",
|
||||
self.key,
|
||||
)
|
||||
return ((), {})
|
||||
return await self.arg_parser.parse_args_split(
|
||||
raw_args, from_validate=from_validate
|
||||
)
|
||||
|
||||
@field_validator("action", mode="before")
|
||||
@classmethod
|
||||
@ -151,11 +190,26 @@ class Command(BaseModel):
|
||||
return ensure_async(action)
|
||||
raise TypeError("Action must be a callable or an instance of BaseAction")
|
||||
|
||||
def get_argument_definitions(self) -> list[dict[str, Any]]:
|
||||
if self.arguments:
|
||||
return self.arguments
|
||||
elif callable(self.argument_config) and isinstance(
|
||||
self.arg_parser, CommandArgumentParser
|
||||
):
|
||||
self.argument_config(self.arg_parser)
|
||||
elif self.auto_args:
|
||||
if isinstance(self.action, BaseAction):
|
||||
infer_target, maybe_metadata = self.action.get_infer_target()
|
||||
# merge metadata with the action's metadata if not already in self.arg_metadata
|
||||
if maybe_metadata:
|
||||
self.arg_metadata = {**maybe_metadata, **self.arg_metadata}
|
||||
return infer_args_from_func(infer_target, self.arg_metadata)
|
||||
elif callable(self.action):
|
||||
return infer_args_from_func(self.action, self.arg_metadata)
|
||||
return []
|
||||
|
||||
def model_post_init(self, _: Any) -> None:
|
||||
"""Post-initialization to set up the action and hooks."""
|
||||
if isinstance(self.arg_parser, CommandArgumentParser):
|
||||
self.arg_parser.command_description = self.description
|
||||
|
||||
if self.retry and isinstance(self.action, Action):
|
||||
self.action.enable_retry()
|
||||
elif self.retry_policy and isinstance(self.action, Action):
|
||||
@ -177,26 +231,17 @@ class Command(BaseModel):
|
||||
if self.logging_hooks and isinstance(self.action, BaseAction):
|
||||
register_debug_hooks(self.action.hooks)
|
||||
|
||||
if self.requires_input is None and self.detect_requires_input:
|
||||
self.requires_input = True
|
||||
self.hidden = True
|
||||
elif self.requires_input is None:
|
||||
self.requires_input = False
|
||||
|
||||
@cached_property
|
||||
def detect_requires_input(self) -> bool:
|
||||
"""Detect if the action requires input based on its type."""
|
||||
if isinstance(self.action, BaseIOAction):
|
||||
return True
|
||||
elif isinstance(self.action, ChainedAction):
|
||||
return (
|
||||
isinstance(self.action.actions[0], BaseIOAction)
|
||||
if self.action.actions
|
||||
else False
|
||||
if self.arg_parser is None and not self.custom_parser:
|
||||
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,
|
||||
)
|
||||
elif isinstance(self.action, ActionGroup):
|
||||
return any(isinstance(action, BaseIOAction) for action in self.action.actions)
|
||||
return False
|
||||
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."""
|
||||
@ -223,7 +268,7 @@ class Command(BaseModel):
|
||||
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)
|
||||
logger.info("[Command:%s] Cancelled by user.", self.key)
|
||||
raise CancelSignal(f"[Command:{self.key}] Cancelled by confirmation.")
|
||||
|
||||
context.start_timer()
|
||||
@ -284,13 +329,39 @@ class Command(BaseModel):
|
||||
|
||||
return FormattedText(prompt)
|
||||
|
||||
@property
|
||||
def usage(self) -> str:
|
||||
"""Generate a help string for the command arguments."""
|
||||
if not self.arg_parser:
|
||||
return "No arguments defined."
|
||||
|
||||
command_keys_text = self.arg_parser.get_command_keys_text(plain_text=True)
|
||||
options_text = self.arg_parser.get_options_text(plain_text=True)
|
||||
return f" {command_keys_text:<20} {options_text} "
|
||||
|
||||
@property
|
||||
def help_signature(self) -> str:
|
||||
"""Generate a help signature for the command."""
|
||||
if self.arg_parser and not self.simple_help_signature:
|
||||
signature = [self.arg_parser.get_usage()]
|
||||
signature.append(f" {self.help_text or self.description}")
|
||||
if self.tags:
|
||||
signature.append(f" [dim]Tags: {', '.join(self.tags)}[/dim]")
|
||||
return "\n".join(signature).strip()
|
||||
|
||||
command_keys = " | ".join(
|
||||
[f"[{self.style}]{self.key}[/{self.style}]"]
|
||||
+ [f"[{self.style}]{alias}[/{self.style}]" for alias in self.aliases]
|
||||
)
|
||||
return f"{command_keys} {self.description}"
|
||||
|
||||
def log_summary(self) -> None:
|
||||
if self._context:
|
||||
self._context.log_summary()
|
||||
|
||||
def show_help(self) -> bool:
|
||||
"""Display the help message for the command."""
|
||||
if self.custom_help:
|
||||
if callable(self.custom_help):
|
||||
output = self.custom_help()
|
||||
if output:
|
||||
console.print(output)
|
||||
|
47
falyx/completer.py
Normal file
47
falyx/completer.py
Normal file
@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
from typing import TYPE_CHECKING, Iterable
|
||||
|
||||
from prompt_toolkit.completion import Completer, Completion
|
||||
from prompt_toolkit.document import Document
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from falyx import Falyx
|
||||
|
||||
|
||||
class FalyxCompleter(Completer):
|
||||
"""Completer for Falyx commands."""
|
||||
|
||||
def __init__(self, falyx: "Falyx"):
|
||||
self.falyx = falyx
|
||||
|
||||
def get_completions(self, document: Document, complete_event) -> Iterable[Completion]:
|
||||
text = document.text_before_cursor
|
||||
try:
|
||||
tokens = shlex.split(text)
|
||||
cursor_at_end_of_token = document.text_before_cursor.endswith((" ", "\t"))
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
if not tokens or (len(tokens) == 1 and not cursor_at_end_of_token):
|
||||
# Suggest command keys and aliases
|
||||
yield from self._suggest_commands(tokens[0] if tokens else "")
|
||||
return
|
||||
|
||||
def _suggest_commands(self, prefix: str) -> Iterable[Completion]:
|
||||
prefix = prefix.upper()
|
||||
keys = [self.falyx.exit_command.key]
|
||||
keys.extend(self.falyx.exit_command.aliases)
|
||||
if self.falyx.history_command:
|
||||
keys.append(self.falyx.history_command.key)
|
||||
keys.extend(self.falyx.history_command.aliases)
|
||||
if self.falyx.help_command:
|
||||
keys.append(self.falyx.help_command.key)
|
||||
keys.extend(self.falyx.help_command.aliases)
|
||||
for cmd in self.falyx.commands.values():
|
||||
keys.append(cmd.key)
|
||||
keys.extend(cmd.aliases)
|
||||
for key in keys:
|
||||
if key.upper().startswith(prefix):
|
||||
yield Completion(key, start_position=-len(prefix))
|
@ -11,17 +11,16 @@ from typing import Any, Callable
|
||||
import toml
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from rich.console import Console
|
||||
|
||||
from falyx.action.action import Action, BaseAction
|
||||
from falyx.action.action import Action
|
||||
from falyx.action.base_action import BaseAction
|
||||
from falyx.command import Command
|
||||
from falyx.console import console
|
||||
from falyx.falyx import Falyx
|
||||
from falyx.logger import logger
|
||||
from falyx.retry import RetryPolicy
|
||||
from falyx.themes import OneColors
|
||||
|
||||
console = Console(color_system="auto")
|
||||
|
||||
|
||||
def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command:
|
||||
if isinstance(obj, (BaseAction, Command)):
|
||||
@ -98,9 +97,9 @@ class RawCommand(BaseModel):
|
||||
retry: bool = False
|
||||
retry_all: bool = False
|
||||
retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
|
||||
requires_input: bool | None = None
|
||||
hidden: bool = False
|
||||
help_text: str = ""
|
||||
help_epilog: str = ""
|
||||
|
||||
@field_validator("retry_policy")
|
||||
@classmethod
|
||||
@ -126,6 +125,7 @@ def convert_commands(raw_commands: list[dict[str, Any]]) -> list[Command]:
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
return commands
|
||||
|
||||
|
||||
|
5
falyx/console.py
Normal file
5
falyx/console.py
Normal file
@ -0,0 +1,5 @@
|
||||
from rich.console import Console
|
||||
|
||||
from falyx.themes import get_nord_theme
|
||||
|
||||
console = Console(color_system="truecolor", theme=get_nord_theme())
|
@ -24,6 +24,8 @@ from typing import Any
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from rich.console import Console
|
||||
|
||||
from falyx.console import console
|
||||
|
||||
|
||||
class ExecutionContext(BaseModel):
|
||||
"""
|
||||
@ -40,7 +42,7 @@ class ExecutionContext(BaseModel):
|
||||
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.
|
||||
exception (BaseException | 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.
|
||||
@ -70,18 +72,20 @@ class ExecutionContext(BaseModel):
|
||||
|
||||
name: str
|
||||
args: tuple = ()
|
||||
kwargs: dict = {}
|
||||
kwargs: dict = Field(default_factory=dict)
|
||||
action: Any
|
||||
result: Any | None = None
|
||||
exception: Exception | None = None
|
||||
exception: BaseException | None = None
|
||||
|
||||
start_time: float | None = None
|
||||
end_time: float | None = None
|
||||
start_wall: datetime | None = None
|
||||
end_wall: datetime | None = None
|
||||
|
||||
index: int | None = None
|
||||
|
||||
extra: dict[str, Any] = Field(default_factory=dict)
|
||||
console: Console = Field(default_factory=lambda: Console(color_system="auto"))
|
||||
console: Console = console
|
||||
|
||||
shared_context: SharedContext | None = None
|
||||
|
||||
@ -118,6 +122,17 @@ class ExecutionContext(BaseModel):
|
||||
def status(self) -> str:
|
||||
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.action} ({signature})"
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"name": self.name,
|
||||
@ -140,9 +155,9 @@ class ExecutionContext(BaseModel):
|
||||
message.append(f"Duration: {summary['duration']:.3f}s | ")
|
||||
|
||||
if summary["exception"]:
|
||||
message.append(f"❌ Exception: {summary['exception']}")
|
||||
message.append(f"Exception: {summary['exception']}")
|
||||
else:
|
||||
message.append(f"✅ Result: {summary['result']}")
|
||||
message.append(f"Result: {summary['result']}")
|
||||
(logger or self.console.print)("".join(message))
|
||||
|
||||
def to_log_line(self) -> str:
|
||||
@ -192,7 +207,7 @@ class SharedContext(BaseModel):
|
||||
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.
|
||||
errors (list[tuple[int, BaseException]]): 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
|
||||
@ -217,7 +232,7 @@ class SharedContext(BaseModel):
|
||||
name: str
|
||||
action: Any
|
||||
results: list[Any] = Field(default_factory=list)
|
||||
errors: list[tuple[int, Exception]] = Field(default_factory=list)
|
||||
errors: list[tuple[int, BaseException]] = Field(default_factory=list)
|
||||
current_index: int = -1
|
||||
is_parallel: bool = False
|
||||
shared_result: Any | None = None
|
||||
@ -229,7 +244,7 @@ class SharedContext(BaseModel):
|
||||
def add_result(self, result: Any) -> None:
|
||||
self.results.append(result)
|
||||
|
||||
def add_error(self, index: int, error: Exception) -> None:
|
||||
def add_error(self, index: int, error: BaseException) -> None:
|
||||
self.errors.append((index, error))
|
||||
|
||||
def set_shared_result(self, result: Any) -> None:
|
||||
|
@ -8,9 +8,9 @@ from falyx.logger import logger
|
||||
def log_before(context: ExecutionContext):
|
||||
"""Log the start of an action."""
|
||||
args = ", ".join(map(repr, context.args))
|
||||
kwargs = ", ".join(f"{k}={v!r}" for k, v in context.kwargs.items())
|
||||
kwargs = ", ".join(f"{key}={value!r}" for key, value in context.kwargs.items())
|
||||
signature = ", ".join(filter(None, [args, kwargs]))
|
||||
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):
|
||||
@ -18,18 +18,18 @@ def log_success(context: ExecutionContext):
|
||||
result_str = repr(context.result)
|
||||
if len(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):
|
||||
"""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):
|
||||
"""Log an error that occurred during the action."""
|
||||
logger.error(
|
||||
"[%s] ❌ Error (%s): %s",
|
||||
"[%s] Error (%s): %s",
|
||||
context.name,
|
||||
type(context.exception).__name__,
|
||||
context.exception,
|
||||
|
@ -30,5 +30,13 @@ class EmptyChainError(FalyxError):
|
||||
"""Exception raised when the chain is empty."""
|
||||
|
||||
|
||||
class EmptyGroupError(FalyxError):
|
||||
"""Exception raised when the chain is empty."""
|
||||
|
||||
|
||||
class EmptyPoolError(FalyxError):
|
||||
"""Exception raised when the chain is empty."""
|
||||
|
||||
|
||||
class CommandArgumentError(FalyxError):
|
||||
"""Exception raised when there is an error in the command argument parser."""
|
||||
|
@ -29,12 +29,14 @@ from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from typing import Dict, List
|
||||
from threading import Lock
|
||||
from typing import Literal
|
||||
|
||||
from rich import box
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from falyx.console import console
|
||||
from falyx.context import ExecutionContext
|
||||
from falyx.logger import logger
|
||||
from falyx.themes import OneColors
|
||||
@ -70,23 +72,30 @@ class ExecutionRegistry:
|
||||
ExecutionRegistry.summary()
|
||||
"""
|
||||
|
||||
_store_by_name: Dict[str, List[ExecutionContext]] = defaultdict(list)
|
||||
_store_all: List[ExecutionContext] = []
|
||||
_console = Console(color_system="auto")
|
||||
_store_by_name: dict[str, list[ExecutionContext]] = defaultdict(list)
|
||||
_store_by_index: dict[int, ExecutionContext] = {}
|
||||
_store_all: list[ExecutionContext] = []
|
||||
_console = Console(color_system="truecolor")
|
||||
_index = 0
|
||||
_lock = Lock()
|
||||
|
||||
@classmethod
|
||||
def record(cls, context: ExecutionContext):
|
||||
"""Record an execution context."""
|
||||
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_all.append(context)
|
||||
|
||||
@classmethod
|
||||
def get_all(cls) -> List[ExecutionContext]:
|
||||
def get_all(cls) -> list[ExecutionContext]:
|
||||
return cls._store_all
|
||||
|
||||
@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, [])
|
||||
|
||||
@classmethod
|
||||
@ -97,11 +106,79 @@ class ExecutionRegistry:
|
||||
def clear(cls):
|
||||
cls._store_by_name.clear()
|
||||
cls._store_all.clear()
|
||||
cls._store_by_index.clear()
|
||||
|
||||
@classmethod
|
||||
def summary(cls):
|
||||
table = Table(title="📊 Execution History", expand=True, box=box.SIMPLE)
|
||||
def summary(
|
||||
cls,
|
||||
name: str = "",
|
||||
index: int | None = None,
|
||||
result_index: 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_index is not None and result_index >= 0:
|
||||
try:
|
||||
result_context = cls._store_by_index[result_index]
|
||||
except KeyError:
|
||||
cls._console.print(
|
||||
f"[{OneColors.DARK_RED}]❌ No execution found for index {result_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("Start", justify="right", style="dim")
|
||||
table.add_column("End", justify="right", style="dim")
|
||||
@ -109,7 +186,7 @@ class ExecutionRegistry:
|
||||
table.add_column("Status", style="bold")
|
||||
table.add_column("Result / Exception", overflow="fold")
|
||||
|
||||
for ctx in cls.get_all():
|
||||
for ctx in contexts:
|
||||
start = (
|
||||
datetime.fromtimestamp(ctx.start_time).strftime("%H:%M:%S")
|
||||
if ctx.start_time
|
||||
@ -122,15 +199,19 @@ class ExecutionRegistry:
|
||||
)
|
||||
duration = f"{ctx.duration:.3f}s" if ctx.duration else "n/a"
|
||||
|
||||
if ctx.exception:
|
||||
status = f"[{OneColors.DARK_RED}]❌ Error"
|
||||
result = repr(ctx.exception)
|
||||
if ctx.exception and status.lower() in ["all", "error"]:
|
||||
final_status = f"[{OneColors.DARK_RED}]❌ Error"
|
||||
final_result = repr(ctx.exception)
|
||||
elif status.lower() in ["all", "success"]:
|
||||
final_status = f"[{OneColors.GREEN}]✅ Success"
|
||||
final_result = repr(ctx.result)
|
||||
if len(final_result) > 1000:
|
||||
final_result = f"{final_result[:1000]}..."
|
||||
else:
|
||||
status = f"[{OneColors.GREEN}]✅ Success"
|
||||
result = repr(ctx.result)
|
||||
if len(result) > 1000:
|
||||
result = f"{result[:1000]}..."
|
||||
continue
|
||||
|
||||
table.add_row(ctx.name, start, end, duration, status, result)
|
||||
table.add_row(
|
||||
str(ctx.index), ctx.name, start, end, duration, final_status, final_result
|
||||
)
|
||||
|
||||
cls._console.print(table)
|
||||
|
487
falyx/falyx.py
487
falyx/falyx.py
@ -1,7 +1,5 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""falyx.py
|
||||
|
||||
Main class for constructing and running Falyx CLI menus.
|
||||
"""Main class for constructing and running Falyx CLI menus.
|
||||
|
||||
Falyx provides a structured, customizable interactive menu system
|
||||
for running commands, actions, and workflows. It supports:
|
||||
@ -25,14 +23,13 @@ import asyncio
|
||||
import logging
|
||||
import shlex
|
||||
import sys
|
||||
from argparse import Namespace
|
||||
from argparse import ArgumentParser, Namespace, _SubParsersAction
|
||||
from difflib import get_close_matches
|
||||
from enum import Enum
|
||||
from functools import cached_property
|
||||
from typing import Any, Callable
|
||||
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.completion import WordCompleter
|
||||
from prompt_toolkit.formatted_text import AnyFormattedText
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
from prompt_toolkit.patch_stdout import patch_stdout
|
||||
@ -42,9 +39,12 @@ from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
from rich.table import Table
|
||||
|
||||
from falyx.action.action import Action, BaseAction
|
||||
from falyx.action.action import Action
|
||||
from falyx.action.base_action import BaseAction
|
||||
from falyx.bottom_bar import BottomBar
|
||||
from falyx.command import Command
|
||||
from falyx.completer import FalyxCompleter
|
||||
from falyx.console import console
|
||||
from falyx.context import ExecutionContext
|
||||
from falyx.debug import log_after, log_before, log_error, log_success
|
||||
from falyx.exceptions import (
|
||||
@ -58,11 +58,12 @@ from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import Hook, HookManager, HookType
|
||||
from falyx.logger import logger
|
||||
from falyx.options_manager import OptionsManager
|
||||
from falyx.parsers import get_arg_parsers
|
||||
from falyx.parser import CommandArgumentParser, FalyxParsers, get_arg_parsers
|
||||
from falyx.protocols import ArgParserProtocol
|
||||
from falyx.retry import RetryPolicy
|
||||
from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
|
||||
from falyx.themes import OneColors, get_nord_theme
|
||||
from falyx.utils import CaseInsensitiveDict, _noop, chunks, get_program_invocation
|
||||
from falyx.themes import OneColors
|
||||
from falyx.utils import CaseInsensitiveDict, _noop, chunks
|
||||
from falyx.version import __version__
|
||||
|
||||
|
||||
@ -82,14 +83,26 @@ class CommandValidator(Validator):
|
||||
self.error_message = error_message
|
||||
|
||||
def validate(self, document) -> None:
|
||||
if not document.text:
|
||||
raise ValidationError(
|
||||
message=self.error_message,
|
||||
cursor_position=len(document.text),
|
||||
)
|
||||
|
||||
async def validate_async(self, document) -> None:
|
||||
text = document.text
|
||||
is_preview, choice, _, __ = self.falyx.get_command(text, from_validate=True)
|
||||
if not text:
|
||||
raise ValidationError(
|
||||
message=self.error_message,
|
||||
cursor_position=len(text),
|
||||
)
|
||||
is_preview, choice, _, __ = await self.falyx.get_command(text, from_validate=True)
|
||||
if is_preview:
|
||||
return None
|
||||
if not choice:
|
||||
raise ValidationError(
|
||||
message=self.error_message,
|
||||
cursor_position=document.get_end_of_document_position(),
|
||||
cursor_position=len(text),
|
||||
)
|
||||
|
||||
|
||||
@ -110,6 +123,8 @@ class Falyx:
|
||||
- Submenu nesting and action chaining
|
||||
- History tracking, help generation, and run key execution modes
|
||||
- Seamless CLI argument parsing and integration via argparse
|
||||
- Declarative option management with OptionsManager
|
||||
- Command level argument parsing and validation
|
||||
- Extensible with user-defined hooks, bottom bars, and custom layouts
|
||||
|
||||
Args:
|
||||
@ -125,7 +140,7 @@ class Falyx:
|
||||
never_prompt (bool): Seed default for `OptionsManager["never_prompt"]`
|
||||
force_confirm (bool): Seed default for `OptionsManager["force_confirm"]`
|
||||
cli_args (Namespace | None): Parsed CLI arguments, usually from argparse.
|
||||
options (OptionsManager | None): Declarative option mappings.
|
||||
options (OptionsManager | None): Declarative option mappings for global state.
|
||||
custom_table (Callable[[Falyx], Table] | Table | None): Custom menu table
|
||||
generator.
|
||||
|
||||
@ -145,6 +160,12 @@ class Falyx:
|
||||
self,
|
||||
title: str | Markdown = "Menu",
|
||||
*,
|
||||
program: str | None = "falyx",
|
||||
usage: str | None = None,
|
||||
description: str | None = "Falyx CLI - Run structured async command workflows.",
|
||||
epilog: str | None = None,
|
||||
version: str = __version__,
|
||||
version_style: str = OneColors.BLUE_b,
|
||||
prompt: str | AnyFormattedText = "> ",
|
||||
columns: int = 3,
|
||||
bottom_bar: BottomBar | str | Callable[[], Any] | None = None,
|
||||
@ -157,11 +178,18 @@ class Falyx:
|
||||
force_confirm: bool = False,
|
||||
cli_args: Namespace | None = None,
|
||||
options: OptionsManager | None = None,
|
||||
render_menu: Callable[["Falyx"], None] | None = None,
|
||||
custom_table: Callable[["Falyx"], Table] | Table | None = None,
|
||||
render_menu: Callable[[Falyx], None] | None = None,
|
||||
custom_table: Callable[[Falyx], Table] | Table | None = None,
|
||||
hide_menu_table: bool = False,
|
||||
) -> None:
|
||||
"""Initializes the Falyx object."""
|
||||
self.title: str | Markdown = title
|
||||
self.program: str | None = program
|
||||
self.usage: str | None = usage
|
||||
self.description: str | None = description
|
||||
self.epilog: str | None = epilog
|
||||
self.version: str = version
|
||||
self.version_style: str = version_style
|
||||
self.prompt: str | AnyFormattedText = prompt
|
||||
self.columns: int = columns
|
||||
self.commands: dict[str, Command] = CaseInsensitiveDict()
|
||||
@ -172,7 +200,7 @@ class Falyx:
|
||||
self.help_command: Command | None = (
|
||||
self._get_help_command() if include_help_command else None
|
||||
)
|
||||
self.console: Console = Console(color_system="auto", theme=get_nord_theme())
|
||||
self.console: Console = console
|
||||
self.welcome_message: str | Markdown | dict[str, Any] = welcome_message
|
||||
self.exit_message: str | Markdown | dict[str, Any] = exit_message
|
||||
self.hooks: HookManager = HookManager()
|
||||
@ -182,8 +210,9 @@ class Falyx:
|
||||
self._never_prompt: bool = never_prompt
|
||||
self._force_confirm: bool = force_confirm
|
||||
self.cli_args: Namespace | None = cli_args
|
||||
self.render_menu: Callable[["Falyx"], None] | None = render_menu
|
||||
self.custom_table: Callable[["Falyx"], Table] | Table | None = custom_table
|
||||
self.render_menu: Callable[[Falyx], None] | None = render_menu
|
||||
self.custom_table: Callable[[Falyx], Table] | Table | None = custom_table
|
||||
self._hide_menu_table: bool = hide_menu_table
|
||||
self.validate_options(cli_args, options)
|
||||
self._prompt_session: PromptSession | None = None
|
||||
self.mode = FalyxMode.MENU
|
||||
@ -265,88 +294,127 @@ class Falyx:
|
||||
action=Action("Exit", action=_noop),
|
||||
aliases=["EXIT", "QUIT"],
|
||||
style=OneColors.DARK_RED,
|
||||
simple_help_signature=True,
|
||||
)
|
||||
|
||||
def _get_history_command(self) -> Command:
|
||||
"""Returns the history command for the menu."""
|
||||
parser = CommandArgumentParser(
|
||||
command_key="Y",
|
||||
command_description="History",
|
||||
command_style=OneColors.DARK_YELLOW,
|
||||
aliases=["HISTORY"],
|
||||
)
|
||||
parser.add_argument(
|
||||
"-n",
|
||||
"--name",
|
||||
help="Filter by execution name.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-i",
|
||||
"--index",
|
||||
type=int,
|
||||
help="Filter by execution index (0-based).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-s",
|
||||
"--status",
|
||||
choices=["all", "success", "error"],
|
||||
default="all",
|
||||
help="Filter by execution status (default: all).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--clear",
|
||||
action="store_true",
|
||||
help="Clear the Execution History.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-r",
|
||||
"--result",
|
||||
type=int,
|
||||
dest="result_index",
|
||||
help="Get the result by index",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-l", "--last-result", action="store_true", help="Get the last result"
|
||||
)
|
||||
return Command(
|
||||
key="Y",
|
||||
description="History",
|
||||
aliases=["HISTORY"],
|
||||
action=Action(name="View Execution History", action=er.summary),
|
||||
style=OneColors.DARK_YELLOW,
|
||||
arg_parser=parser,
|
||||
help_text="View the execution history of commands.",
|
||||
)
|
||||
|
||||
async def _show_help(self):
|
||||
table = Table(title="[bold cyan]Help Menu[/]", box=box.SIMPLE)
|
||||
table.add_column("Key", style="bold", no_wrap=True)
|
||||
table.add_column("Aliases", style="dim", no_wrap=True)
|
||||
table.add_column("Description", style="dim", overflow="fold")
|
||||
table.add_column("Tags", style="dim", no_wrap=True)
|
||||
|
||||
for command in self.commands.values():
|
||||
help_text = command.help_text or command.description
|
||||
if command.requires_input:
|
||||
help_text += " [dim](requires input)[/dim]"
|
||||
table.add_row(
|
||||
f"[{command.style}]{command.key}[/]",
|
||||
", ".join(command.aliases) if command.aliases else "",
|
||||
help_text,
|
||||
", ".join(command.tags) if command.tags else "",
|
||||
async def _show_help(self, tag: str = "") -> None:
|
||||
if tag:
|
||||
table = Table(
|
||||
title=tag.upper(),
|
||||
title_justify="left",
|
||||
show_header=False,
|
||||
box=box.SIMPLE,
|
||||
show_footer=False,
|
||||
)
|
||||
|
||||
table.add_row(
|
||||
f"[{self.exit_command.style}]{self.exit_command.key}[/]",
|
||||
", ".join(self.exit_command.aliases),
|
||||
"Exit this menu or program",
|
||||
)
|
||||
|
||||
if self.history_command:
|
||||
table.add_row(
|
||||
f"[{self.history_command.style}]{self.history_command.key}[/]",
|
||||
", ".join(self.history_command.aliases),
|
||||
"History of executed actions",
|
||||
tag_lower = tag.lower()
|
||||
commands = [
|
||||
command
|
||||
for command in self.commands.values()
|
||||
if any(tag_lower == tag.lower() for tag in command.tags)
|
||||
]
|
||||
for command in commands:
|
||||
table.add_row(command.help_signature)
|
||||
self.console.print(table)
|
||||
return
|
||||
else:
|
||||
table = Table(
|
||||
title="Help",
|
||||
title_justify="left",
|
||||
title_style=OneColors.LIGHT_YELLOW_b,
|
||||
show_header=False,
|
||||
show_footer=False,
|
||||
box=box.SIMPLE,
|
||||
)
|
||||
|
||||
for command in self.commands.values():
|
||||
table.add_row(command.help_signature)
|
||||
if self.help_command:
|
||||
table.add_row(
|
||||
f"[{self.help_command.style}]{self.help_command.key}[/]",
|
||||
", ".join(self.help_command.aliases),
|
||||
"Show this help menu",
|
||||
)
|
||||
|
||||
self.console.print(table, justify="center")
|
||||
if self.mode == FalyxMode.MENU:
|
||||
self.console.print(
|
||||
f"📦 Tip: '[{OneColors.LIGHT_YELLOW}]?[KEY][/]' to preview a command "
|
||||
"before running it.\n",
|
||||
justify="center",
|
||||
)
|
||||
table.add_row(self.help_command.help_signature)
|
||||
if self.history_command:
|
||||
table.add_row(self.history_command.help_signature)
|
||||
table.add_row(self.exit_command.help_signature)
|
||||
table.add_row(f"Tip: '[{OneColors.LIGHT_YELLOW}]?[KEY][/]' to preview a command ")
|
||||
self.console.print(table)
|
||||
|
||||
def _get_help_command(self) -> Command:
|
||||
"""Returns the help command for the menu."""
|
||||
parser = CommandArgumentParser(
|
||||
command_key="H",
|
||||
command_description="Help",
|
||||
command_style=OneColors.LIGHT_YELLOW,
|
||||
aliases=["?", "HELP", "LIST"],
|
||||
)
|
||||
parser.add_argument(
|
||||
"-t",
|
||||
"--tag",
|
||||
nargs="?",
|
||||
default="",
|
||||
help="Optional tag to filter commands by.",
|
||||
)
|
||||
return Command(
|
||||
key="H",
|
||||
aliases=["HELP", "?"],
|
||||
aliases=["?", "HELP", "LIST"],
|
||||
description="Help",
|
||||
help_text="Show this help menu",
|
||||
action=Action("Help", self._show_help),
|
||||
style=OneColors.LIGHT_YELLOW,
|
||||
arg_parser=parser,
|
||||
)
|
||||
|
||||
def _get_completer(self) -> WordCompleter:
|
||||
def _get_completer(self) -> FalyxCompleter:
|
||||
"""Completer to provide auto-completion for the menu commands."""
|
||||
keys = [self.exit_command.key]
|
||||
keys.extend(self.exit_command.aliases)
|
||||
if self.history_command:
|
||||
keys.append(self.history_command.key)
|
||||
keys.extend(self.history_command.aliases)
|
||||
if self.help_command:
|
||||
keys.append(self.help_command.key)
|
||||
keys.extend(self.help_command.aliases)
|
||||
for cmd in self.commands.values():
|
||||
keys.append(cmd.key)
|
||||
keys.extend(cmd.aliases)
|
||||
return WordCompleter(keys, ignore_case=True)
|
||||
return FalyxCompleter(self)
|
||||
|
||||
def _get_validator_error_message(self) -> str:
|
||||
"""Validator to check if the input is a valid command or toggle key."""
|
||||
@ -443,7 +511,9 @@ class Falyx:
|
||||
validator=CommandValidator(self, self._get_validator_error_message()),
|
||||
bottom_toolbar=self._get_bottom_bar_render(),
|
||||
key_bindings=self.key_bindings,
|
||||
validate_while_typing=False,
|
||||
validate_while_typing=True,
|
||||
interrupt_exception=QuitSignal,
|
||||
eof_exception=QuitSignal,
|
||||
)
|
||||
return self._prompt_session
|
||||
|
||||
@ -524,7 +594,7 @@ class Falyx:
|
||||
key: str = "X",
|
||||
description: str = "Exit",
|
||||
aliases: list[str] | None = None,
|
||||
action: Callable[[], Any] | None = None,
|
||||
action: Callable[..., Any] | None = None,
|
||||
style: str = OneColors.DARK_RED,
|
||||
confirm: bool = False,
|
||||
confirm_message: str = "Are you sure?",
|
||||
@ -551,7 +621,9 @@ class Falyx:
|
||||
if not isinstance(submenu, Falyx):
|
||||
raise NotAFalyxError("submenu must be an instance of Falyx.")
|
||||
self._validate_command_key(key)
|
||||
self.add_command(key, description, submenu.menu, style=style)
|
||||
self.add_command(
|
||||
key, description, submenu.menu, style=style, simple_help_signature=True
|
||||
)
|
||||
if submenu.exit_command.key == "X":
|
||||
submenu.update_exit_command(key="B", description="Back", aliases=["BACK"])
|
||||
|
||||
@ -578,13 +650,14 @@ class Falyx:
|
||||
self,
|
||||
key: str,
|
||||
description: str,
|
||||
action: BaseAction | Callable[[], Any],
|
||||
action: BaseAction | Callable[..., Any],
|
||||
*,
|
||||
args: tuple = (),
|
||||
kwargs: dict[str, Any] | None = None,
|
||||
hidden: bool = False,
|
||||
aliases: list[str] | None = None,
|
||||
help_text: str = "",
|
||||
help_epilog: str = "",
|
||||
style: str = OneColors.WHITE,
|
||||
confirm: bool = False,
|
||||
confirm_message: str = "Are you sure?",
|
||||
@ -605,10 +678,25 @@ class Falyx:
|
||||
retry: bool = False,
|
||||
retry_all: bool = False,
|
||||
retry_policy: RetryPolicy | None = None,
|
||||
requires_input: bool | None = None,
|
||||
arg_parser: CommandArgumentParser | None = None,
|
||||
arguments: list[dict[str, Any]] | None = None,
|
||||
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]] | None = None,
|
||||
simple_help_signature: bool = False,
|
||||
) -> Command:
|
||||
"""Adds an command to the menu, preventing duplicates."""
|
||||
self._validate_command_key(key)
|
||||
|
||||
if arg_parser:
|
||||
if not isinstance(arg_parser, CommandArgumentParser):
|
||||
raise NotAFalyxError(
|
||||
"arg_parser must be an instance of CommandArgumentParser."
|
||||
)
|
||||
arg_parser = arg_parser
|
||||
|
||||
command = Command(
|
||||
key=key,
|
||||
description=description,
|
||||
@ -618,6 +706,7 @@ class Falyx:
|
||||
hidden=hidden,
|
||||
aliases=aliases if aliases else [],
|
||||
help_text=help_text,
|
||||
help_epilog=help_epilog,
|
||||
style=style,
|
||||
confirm=confirm,
|
||||
confirm_message=confirm_message,
|
||||
@ -632,8 +721,15 @@ class Falyx:
|
||||
retry=retry,
|
||||
retry_all=retry_all,
|
||||
retry_policy=retry_policy or RetryPolicy(),
|
||||
requires_input=requires_input,
|
||||
options_manager=self.options,
|
||||
arg_parser=arg_parser,
|
||||
arguments=arguments or [],
|
||||
argument_config=argument_config,
|
||||
custom_parser=custom_parser,
|
||||
custom_help=custom_help,
|
||||
auto_args=auto_args,
|
||||
arg_metadata=arg_metadata or {},
|
||||
simple_help_signature=simple_help_signature,
|
||||
)
|
||||
|
||||
if hooks:
|
||||
@ -658,16 +754,16 @@ class Falyx:
|
||||
def get_bottom_row(self) -> list[str]:
|
||||
"""Returns the bottom row of the table for displaying additional commands."""
|
||||
bottom_row = []
|
||||
if self.history_command:
|
||||
bottom_row.append(
|
||||
f"[{self.history_command.key}] [{self.history_command.style}]"
|
||||
f"{self.history_command.description}"
|
||||
)
|
||||
if self.help_command:
|
||||
bottom_row.append(
|
||||
f"[{self.help_command.key}] [{self.help_command.style}]"
|
||||
f"{self.help_command.description}"
|
||||
)
|
||||
if self.history_command:
|
||||
bottom_row.append(
|
||||
f"[{self.history_command.key}] [{self.history_command.style}]"
|
||||
f"{self.history_command.description}"
|
||||
)
|
||||
bottom_row.append(
|
||||
f"[{self.exit_command.key}] [{self.exit_command.style}]"
|
||||
f"{self.exit_command.description}"
|
||||
@ -679,7 +775,7 @@ class Falyx:
|
||||
Build the standard table layout. Developers can subclass or call this
|
||||
in custom tables.
|
||||
"""
|
||||
table = Table(title=self.title, show_header=False, box=box.SIMPLE, expand=True) # type: ignore[arg-type]
|
||||
table = Table(title=self.title, show_header=False, box=box.SIMPLE) # type: ignore[arg-type]
|
||||
visible_commands = [item for item in self.commands.items() if not item[1].hidden]
|
||||
for chunk in chunks(visible_commands, self.columns):
|
||||
row = []
|
||||
@ -695,7 +791,12 @@ class Falyx:
|
||||
def table(self) -> Table:
|
||||
"""Creates or returns a custom table to display the menu commands."""
|
||||
if callable(self.custom_table):
|
||||
return self.custom_table(self)
|
||||
custom_table = self.custom_table(self)
|
||||
if not isinstance(custom_table, Table):
|
||||
raise FalyxError(
|
||||
"custom_table must return an instance of rich.table.Table."
|
||||
)
|
||||
return custom_table
|
||||
elif isinstance(self.custom_table, Table):
|
||||
return self.custom_table
|
||||
else:
|
||||
@ -706,7 +807,7 @@ class Falyx:
|
||||
return True, input_str[1:].strip()
|
||||
return False, input_str.strip()
|
||||
|
||||
def get_command(
|
||||
async def get_command(
|
||||
self, raw_choices: str, from_validate=False
|
||||
) -> tuple[bool, Command | None, tuple, dict[str, Any]]:
|
||||
"""
|
||||
@ -715,13 +816,16 @@ class Falyx:
|
||||
"""
|
||||
args = ()
|
||||
kwargs: dict[str, Any] = {}
|
||||
choice, *input_args = shlex.split(raw_choices)
|
||||
try:
|
||||
choice, *input_args = shlex.split(raw_choices)
|
||||
except ValueError:
|
||||
return False, None, args, kwargs
|
||||
is_preview, choice = self.parse_preview_command(choice)
|
||||
if is_preview and not choice and self.help_command:
|
||||
is_preview = False
|
||||
choice = "?"
|
||||
elif is_preview and not choice:
|
||||
# No help command enabled
|
||||
# No help (list) command enabled
|
||||
if not from_validate:
|
||||
self.console.print(
|
||||
f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode."
|
||||
@ -730,29 +834,39 @@ class Falyx:
|
||||
|
||||
choice = choice.upper()
|
||||
name_map = self._name_map
|
||||
if choice in name_map:
|
||||
if not from_validate:
|
||||
logger.info("Command '%s' selected.", choice)
|
||||
if input_args and name_map[choice].arg_parser:
|
||||
try:
|
||||
args, kwargs = name_map[choice].parse_args(input_args)
|
||||
except CommandArgumentError as error:
|
||||
if not from_validate:
|
||||
if not name_map[choice].show_help():
|
||||
self.console.print(
|
||||
f"[{OneColors.DARK_RED}]❌ Invalid arguments for '{choice}': {error}"
|
||||
)
|
||||
else:
|
||||
name_map[choice].show_help()
|
||||
raise ValidationError(
|
||||
message=str(error), cursor_position=len(raw_choices)
|
||||
)
|
||||
return is_preview, None, args, kwargs
|
||||
return is_preview, name_map[choice], args, kwargs
|
||||
run_command = None
|
||||
if name_map.get(choice):
|
||||
run_command = name_map[choice]
|
||||
else:
|
||||
prefix_matches = [
|
||||
cmd for key, cmd in name_map.items() if key.startswith(choice)
|
||||
]
|
||||
if len(prefix_matches) == 1:
|
||||
run_command = prefix_matches[0]
|
||||
|
||||
prefix_matches = [cmd for key, cmd in name_map.items() if key.startswith(choice)]
|
||||
if len(prefix_matches) == 1:
|
||||
return is_preview, prefix_matches[0], args, kwargs
|
||||
if run_command:
|
||||
if not from_validate:
|
||||
logger.info("Command '%s' selected.", run_command.key)
|
||||
if is_preview:
|
||||
return True, run_command, args, kwargs
|
||||
elif self.mode in {FalyxMode.RUN, FalyxMode.RUN_ALL, FalyxMode.PREVIEW}:
|
||||
return False, run_command, args, kwargs
|
||||
try:
|
||||
args, kwargs = await run_command.parse_args(input_args, from_validate)
|
||||
except (CommandArgumentError, Exception) as error:
|
||||
if not from_validate:
|
||||
run_command.show_help()
|
||||
self.console.print(
|
||||
f"[{OneColors.DARK_RED}]❌ [{run_command.key}]: {error}"
|
||||
)
|
||||
else:
|
||||
raise ValidationError(
|
||||
message=str(error), cursor_position=len(raw_choices)
|
||||
)
|
||||
return is_preview, None, args, kwargs
|
||||
except HelpSignal:
|
||||
return True, None, args, kwargs
|
||||
return is_preview, run_command, args, kwargs
|
||||
|
||||
fuzzy_matches = get_close_matches(choice, list(name_map.keys()), n=3, cutoff=0.7)
|
||||
if fuzzy_matches:
|
||||
@ -761,22 +875,35 @@ class Falyx:
|
||||
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'. "
|
||||
"Did you mean:"
|
||||
)
|
||||
for match in fuzzy_matches:
|
||||
cmd = name_map[match]
|
||||
self.console.print(f" • [bold]{match}[/] → {cmd.description}")
|
||||
for match in fuzzy_matches:
|
||||
cmd = name_map[match]
|
||||
self.console.print(f" • [bold]{match}[/] → {cmd.description}")
|
||||
else:
|
||||
raise ValidationError(
|
||||
message=f"Unknown command '{choice}'. Did you mean: "
|
||||
f"{', '.join(fuzzy_matches)}?",
|
||||
cursor_position=len(raw_choices),
|
||||
)
|
||||
else:
|
||||
if not from_validate:
|
||||
self.console.print(
|
||||
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'[/]"
|
||||
)
|
||||
else:
|
||||
raise ValidationError(
|
||||
message=f"Unknown command '{choice}'.",
|
||||
cursor_position=len(raw_choices),
|
||||
)
|
||||
return is_preview, None, args, kwargs
|
||||
|
||||
def _create_context(self, selected_command: Command) -> ExecutionContext:
|
||||
"""Creates a context dictionary for the selected command."""
|
||||
def _create_context(
|
||||
self, selected_command: Command, args: tuple, kwargs: dict[str, Any]
|
||||
) -> ExecutionContext:
|
||||
"""Creates an ExecutionContext object for the selected command."""
|
||||
return ExecutionContext(
|
||||
name=selected_command.description,
|
||||
args=tuple(),
|
||||
kwargs={},
|
||||
args=args,
|
||||
kwargs=kwargs,
|
||||
action=selected_command,
|
||||
)
|
||||
|
||||
@ -794,7 +921,7 @@ class Falyx:
|
||||
"""Processes the action of the selected command."""
|
||||
with patch_stdout(raw=True):
|
||||
choice = await self.prompt_session.prompt_async()
|
||||
is_preview, selected_command, args, kwargs = self.get_command(choice)
|
||||
is_preview, selected_command, args, kwargs = await self.get_command(choice)
|
||||
if not selected_command:
|
||||
logger.info("Invalid command '%s'.", choice)
|
||||
return True
|
||||
@ -804,26 +931,16 @@ class Falyx:
|
||||
await selected_command.preview()
|
||||
return True
|
||||
|
||||
if selected_command.requires_input:
|
||||
program = get_program_invocation()
|
||||
self.console.print(
|
||||
f"[{OneColors.LIGHT_YELLOW}]⚠️ Command '{selected_command.key}' requires"
|
||||
f" input and must be run via [{OneColors.MAGENTA}]'{program} run"
|
||||
f"'[{OneColors.LIGHT_YELLOW}] with proper piping or arguments.[/]"
|
||||
)
|
||||
return True
|
||||
|
||||
self.last_run_command = selected_command
|
||||
|
||||
if selected_command == self.exit_command:
|
||||
logger.info("🔙 Back selected: exiting %s", self.get_title())
|
||||
logger.info("Back selected: exiting %s", self.get_title())
|
||||
return False
|
||||
|
||||
context = self._create_context(selected_command)
|
||||
context = self._create_context(selected_command, args, kwargs)
|
||||
context.start_timer()
|
||||
try:
|
||||
await self.hooks.trigger(HookType.BEFORE, context)
|
||||
print(args, kwargs)
|
||||
result = await selected_command(*args, **kwargs)
|
||||
context.result = result
|
||||
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||
@ -846,7 +963,7 @@ class Falyx:
|
||||
) -> Any:
|
||||
"""Run a command by key without displaying the menu (non-interactive mode)."""
|
||||
self.debug_hooks()
|
||||
is_preview, selected_command, _, __ = self.get_command(command_key)
|
||||
is_preview, selected_command, _, __ = await self.get_command(command_key)
|
||||
kwargs = kwargs or {}
|
||||
|
||||
self.last_run_command = selected_command
|
||||
@ -860,12 +977,12 @@ class Falyx:
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"[run_key] 🚀 Executing: %s — %s",
|
||||
"[run_key] Executing: %s — %s",
|
||||
selected_command.key,
|
||||
selected_command.description,
|
||||
)
|
||||
|
||||
context = self._create_context(selected_command)
|
||||
context = self._create_context(selected_command, args, kwargs)
|
||||
context.start_timer()
|
||||
try:
|
||||
await self.hooks.trigger(HookType.BEFORE, context)
|
||||
@ -873,10 +990,10 @@ class Falyx:
|
||||
context.result = result
|
||||
|
||||
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||
logger.info("[run_key] ✅ '%s' complete.", selected_command.description)
|
||||
logger.info("[run_key] '%s' complete.", selected_command.description)
|
||||
except (KeyboardInterrupt, EOFError) as error:
|
||||
logger.warning(
|
||||
"[run_key] ⚠️ Interrupted by user: %s", selected_command.description
|
||||
"[run_key] Interrupted by user: %s", selected_command.description
|
||||
)
|
||||
raise FalyxError(
|
||||
f"[run_key] ⚠️ '{selected_command.description}' interrupted by user."
|
||||
@ -885,7 +1002,7 @@ class Falyx:
|
||||
context.exception = error
|
||||
await self.hooks.trigger(HookType.ON_ERROR, context)
|
||||
logger.error(
|
||||
"[run_key] ❌ Failed: %s — %s: %s",
|
||||
"[run_key] Failed: %s — %s: %s",
|
||||
selected_command.description,
|
||||
type(error).__name__,
|
||||
error,
|
||||
@ -939,16 +1056,17 @@ class Falyx:
|
||||
|
||||
async def menu(self) -> None:
|
||||
"""Runs the menu and handles user input."""
|
||||
logger.info("Running menu: %s", self.get_title())
|
||||
logger.info("Starting menu: %s", self.get_title())
|
||||
self.debug_hooks()
|
||||
if self.welcome_message:
|
||||
self.print_message(self.welcome_message)
|
||||
try:
|
||||
while True:
|
||||
if callable(self.render_menu):
|
||||
self.render_menu(self)
|
||||
else:
|
||||
self.console.print(self.table, justify="center")
|
||||
if not self.options.get("hide_menu_table", self._hide_menu_table):
|
||||
if callable(self.render_menu):
|
||||
self.render_menu(self)
|
||||
else:
|
||||
self.console.print(self.table, justify="center")
|
||||
try:
|
||||
task = asyncio.create_task(self.process_command())
|
||||
should_continue = await task
|
||||
@ -958,49 +1076,77 @@ class Falyx:
|
||||
logger.info("EOF or KeyboardInterrupt. Exiting menu.")
|
||||
break
|
||||
except QuitSignal:
|
||||
logger.info("QuitSignal received. Exiting menu.")
|
||||
logger.info("[QuitSignal]. <- Exiting menu.")
|
||||
break
|
||||
except BackSignal:
|
||||
logger.info("BackSignal received.")
|
||||
logger.info("[BackSignal]. <- Returning to the menu.")
|
||||
except CancelSignal:
|
||||
logger.info("CancelSignal received.")
|
||||
except HelpSignal:
|
||||
logger.info("HelpSignal received.")
|
||||
logger.info("[CancelSignal]. <- Returning to the menu.")
|
||||
finally:
|
||||
logger.info("Exiting menu: %s", self.get_title())
|
||||
if self.exit_message:
|
||||
self.print_message(self.exit_message)
|
||||
|
||||
async def run(self) -> None:
|
||||
async def run(
|
||||
self,
|
||||
falyx_parsers: FalyxParsers | None = None,
|
||||
root_parser: ArgumentParser | None = None,
|
||||
subparsers: _SubParsersAction | None = None,
|
||||
callback: Callable[..., Any] | None = None,
|
||||
) -> None:
|
||||
"""Run Falyx CLI with structured subcommands."""
|
||||
if not self.cli_args:
|
||||
self.cli_args = get_arg_parsers().root.parse_args()
|
||||
if self.cli_args:
|
||||
raise FalyxError(
|
||||
"Run is incompatible with CLI arguments. Use 'run_key' instead."
|
||||
)
|
||||
if falyx_parsers:
|
||||
if not isinstance(falyx_parsers, FalyxParsers):
|
||||
raise FalyxError("falyx_parsers must be an instance of FalyxParsers.")
|
||||
else:
|
||||
falyx_parsers = get_arg_parsers(
|
||||
self.program,
|
||||
self.usage,
|
||||
self.description,
|
||||
self.epilog,
|
||||
commands=self.commands,
|
||||
root_parser=root_parser,
|
||||
subparsers=subparsers,
|
||||
)
|
||||
self.cli_args = falyx_parsers.parse_args()
|
||||
self.options.from_namespace(self.cli_args, "cli_args")
|
||||
|
||||
if callback:
|
||||
if not callable(callback):
|
||||
raise FalyxError("Callback must be a callable function.")
|
||||
callback(self.cli_args)
|
||||
|
||||
if not self.options.get("never_prompt"):
|
||||
self.options.set("never_prompt", self._never_prompt)
|
||||
|
||||
if not self.options.get("force_confirm"):
|
||||
self.options.set("force_confirm", self._force_confirm)
|
||||
|
||||
if not self.options.get("hide_menu_table"):
|
||||
self.options.set("hide_menu_table", self._hide_menu_table)
|
||||
|
||||
if self.cli_args.verbose:
|
||||
logging.getLogger("falyx").setLevel(logging.DEBUG)
|
||||
|
||||
if self.cli_args.debug_hooks:
|
||||
logger.debug("✅ Enabling global debug hooks for all commands")
|
||||
logger.debug("Enabling global debug hooks for all commands")
|
||||
self.register_all_with_debug_hooks()
|
||||
|
||||
if self.cli_args.command == "list":
|
||||
await self._show_help()
|
||||
await self._show_help(tag=self.cli_args.tag)
|
||||
sys.exit(0)
|
||||
|
||||
if self.cli_args.command == "version" or self.cli_args.version:
|
||||
self.console.print(f"[{OneColors.GREEN_b}]Falyx CLI v{__version__}[/]")
|
||||
self.console.print(f"[{self.version_style}]{self.program} v{self.version}[/]")
|
||||
sys.exit(0)
|
||||
|
||||
if self.cli_args.command == "preview":
|
||||
self.mode = FalyxMode.PREVIEW
|
||||
_, command, args, kwargs = self.get_command(self.cli_args.name)
|
||||
_, command, args, kwargs = await self.get_command(self.cli_args.name)
|
||||
if not command:
|
||||
self.console.print(
|
||||
f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found."
|
||||
@ -1014,7 +1160,7 @@ class Falyx:
|
||||
|
||||
if self.cli_args.command == "run":
|
||||
self.mode = FalyxMode.RUN
|
||||
is_preview, command, _, __ = self.get_command(self.cli_args.name)
|
||||
is_preview, command, _, __ = await self.get_command(self.cli_args.name)
|
||||
if is_preview:
|
||||
if command is None:
|
||||
sys.exit(1)
|
||||
@ -1025,14 +1171,27 @@ class Falyx:
|
||||
sys.exit(1)
|
||||
self._set_retry_policy(command)
|
||||
try:
|
||||
args, kwargs = command.parse_args(self.cli_args.command_args)
|
||||
args, kwargs = await command.parse_args(self.cli_args.command_args)
|
||||
except HelpSignal:
|
||||
sys.exit(0)
|
||||
except CommandArgumentError as error:
|
||||
self.console.print(f"[{OneColors.DARK_RED}]❌ ['{command.key}'] {error}")
|
||||
command.show_help()
|
||||
sys.exit(1)
|
||||
try:
|
||||
await self.run_key(self.cli_args.name, args=args, kwargs=kwargs)
|
||||
except FalyxError as error:
|
||||
self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]")
|
||||
sys.exit(1)
|
||||
except QuitSignal:
|
||||
logger.info("[QuitSignal]. <- Exiting run.")
|
||||
sys.exit(0)
|
||||
except BackSignal:
|
||||
logger.info("[BackSignal]. <- Exiting run.")
|
||||
sys.exit(0)
|
||||
except CancelSignal:
|
||||
logger.info("[CancelSignal]. <- Exiting run.")
|
||||
sys.exit(0)
|
||||
|
||||
if self.cli_args.summary:
|
||||
er.summary()
|
||||
@ -1056,9 +1215,23 @@ class Falyx:
|
||||
f"[{OneColors.CYAN_b}]🚀 Running all commands with tag:[/] "
|
||||
f"{self.cli_args.tag}"
|
||||
)
|
||||
|
||||
for cmd in matching:
|
||||
self._set_retry_policy(cmd)
|
||||
await self.run_key(cmd.key)
|
||||
try:
|
||||
await self.run_key(cmd.key)
|
||||
except FalyxError as error:
|
||||
self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]")
|
||||
sys.exit(1)
|
||||
except QuitSignal:
|
||||
logger.info("[QuitSignal]. <- Exiting run.")
|
||||
sys.exit(0)
|
||||
except BackSignal:
|
||||
logger.info("[BackSignal]. <- Exiting run.")
|
||||
sys.exit(0)
|
||||
except CancelSignal:
|
||||
logger.info("[CancelSignal]. <- Exiting run.")
|
||||
sys.exit(0)
|
||||
|
||||
if self.cli_args.summary:
|
||||
er.summary()
|
||||
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from enum import Enum
|
||||
from typing import Awaitable, Callable, Dict, List, Optional, Union
|
||||
from typing import Awaitable, Callable, Union
|
||||
|
||||
from falyx.context import ExecutionContext
|
||||
from falyx.logger import logger
|
||||
@ -24,7 +24,7 @@ class HookType(Enum):
|
||||
ON_TEARDOWN = "on_teardown"
|
||||
|
||||
@classmethod
|
||||
def choices(cls) -> List[HookType]:
|
||||
def choices(cls) -> list[HookType]:
|
||||
"""Return a list of all hook type choices."""
|
||||
return list(cls)
|
||||
|
||||
@ -37,16 +37,17 @@ class HookManager:
|
||||
"""HookManager"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._hooks: Dict[HookType, List[Hook]] = {
|
||||
self._hooks: dict[HookType, list[Hook]] = {
|
||||
hook_type: [] for hook_type in HookType
|
||||
}
|
||||
|
||||
def register(self, hook_type: HookType, hook: Hook):
|
||||
if hook_type not in HookType:
|
||||
raise ValueError(f"Unsupported hook type: {hook_type}")
|
||||
def register(self, hook_type: HookType | str, hook: Hook):
|
||||
"""Raises ValueError if the hook type is not supported."""
|
||||
if not isinstance(hook_type, HookType):
|
||||
hook_type = HookType(hook_type)
|
||||
self._hooks[hook_type].append(hook)
|
||||
|
||||
def clear(self, hook_type: Optional[HookType] = None):
|
||||
def clear(self, hook_type: HookType | None = None):
|
||||
if hook_type:
|
||||
self._hooks[hook_type] = []
|
||||
else:
|
||||
@ -64,7 +65,7 @@ class HookManager:
|
||||
hook(context)
|
||||
except Exception as hook_error:
|
||||
logger.warning(
|
||||
"⚠️ Hook '%s' raised an exception during '%s' for '%s': %s",
|
||||
"[Hook:%s] raised an exception during '%s' for '%s': %s",
|
||||
hook.__name__,
|
||||
hook_type,
|
||||
context.name,
|
||||
|
@ -56,10 +56,10 @@ class CircuitBreaker:
|
||||
if self.open_until:
|
||||
if time.time() < self.open_until:
|
||||
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:
|
||||
logger.info("🟢 Circuit closed again for '%s'.")
|
||||
logger.info("Circuit closed again for '%s'.")
|
||||
self.failures = 0
|
||||
self.open_until = None
|
||||
|
||||
@ -67,7 +67,7 @@ class CircuitBreaker:
|
||||
name = context.name
|
||||
self.failures += 1
|
||||
logger.warning(
|
||||
"⚠️ CircuitBreaker: '%s' failure %s/%s.",
|
||||
"CircuitBreaker: '%s' failure %s/%s.",
|
||||
name,
|
||||
self.failures,
|
||||
self.max_failures,
|
||||
@ -75,7 +75,7 @@ class CircuitBreaker:
|
||||
if self.failures >= self.max_failures:
|
||||
self.open_until = time.time() + self.reset_timeout
|
||||
logger.error(
|
||||
"🔴 Circuit opened for '%s' until %s.", name, time.ctime(self.open_until)
|
||||
"Circuit opened for '%s' until %s.", name, time.ctime(self.open_until)
|
||||
)
|
||||
|
||||
def after_hook(self, _: ExecutionContext):
|
||||
@ -87,4 +87,4 @@ class CircuitBreaker:
|
||||
def reset(self):
|
||||
self.failures = 0
|
||||
self.open_until = None
|
||||
logger.info("🔄 Circuit reset.")
|
||||
logger.info("Circuit reset.")
|
||||
|
@ -2,7 +2,7 @@
|
||||
"""init.py"""
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
from falyx.console import console
|
||||
|
||||
TEMPLATE_TASKS = """\
|
||||
# This file is used by falyx.yaml to define CLI actions.
|
||||
@ -11,9 +11,7 @@ TEMPLATE_TASKS = """\
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from falyx.action import Action, ChainedAction
|
||||
from falyx.io_action import ShellAction
|
||||
from falyx.selection_action import SelectionAction
|
||||
from falyx.action import Action, ChainedAction, ShellAction, SelectionAction
|
||||
|
||||
|
||||
post_ids = ["1", "2", "3", "4", "5"]
|
||||
@ -100,10 +98,8 @@ commands:
|
||||
aliases: [clean, cleanup]
|
||||
"""
|
||||
|
||||
console = Console(color_system="auto")
|
||||
|
||||
|
||||
def init_project(name: str = ".") -> None:
|
||||
def init_project(name: str) -> None:
|
||||
target = Path(name).resolve()
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
@ -2,4 +2,4 @@
|
||||
"""logger.py"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("falyx")
|
||||
logger: logging.Logger = logging.getLogger("falyx")
|
||||
|
@ -2,7 +2,9 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from falyx.action import BaseAction
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
|
||||
from falyx.action.base_action import BaseAction
|
||||
from falyx.signals import BackSignal, QuitSignal
|
||||
from falyx.themes import OneColors
|
||||
from falyx.utils import CaseInsensitiveDict
|
||||
@ -26,6 +28,12 @@ class MenuOption:
|
||||
"""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):
|
||||
"""
|
||||
@ -33,7 +41,7 @@ class MenuOptionMap(CaseInsensitiveDict):
|
||||
and special signal entries like Quit and Back.
|
||||
"""
|
||||
|
||||
RESERVED_KEYS = {"Q", "B"}
|
||||
RESERVED_KEYS = {"B", "X"}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@ -49,14 +57,14 @@ class MenuOptionMap(CaseInsensitiveDict):
|
||||
def _inject_reserved_defaults(self):
|
||||
from falyx.action import SignalAction
|
||||
|
||||
self._add_reserved(
|
||||
"Q",
|
||||
MenuOption("Exit", SignalAction("Quit", QuitSignal()), OneColors.DARK_RED),
|
||||
)
|
||||
self._add_reserved(
|
||||
"B",
|
||||
MenuOption("Back", SignalAction("Back", BackSignal()), OneColors.DARK_YELLOW),
|
||||
)
|
||||
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."""
|
||||
@ -78,8 +86,20 @@ class MenuOptionMap(CaseInsensitiveDict):
|
||||
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 k, v in super().items():
|
||||
if not include_reserved and k in self.RESERVED_KEYS:
|
||||
for key, option in super().items():
|
||||
if not include_reserved and key in self.RESERVED_KEYS:
|
||||
continue
|
||||
yield k, v
|
||||
yield key, option
|
||||
|
0
falyx/parser/.pytyped
Normal file
0
falyx/parser/.pytyped
Normal file
21
falyx/parser/__init__.py
Normal file
21
falyx/parser/__init__.py
Normal file
@ -0,0 +1,21 @@
|
||||
"""
|
||||
Falyx CLI Framework
|
||||
|
||||
Copyright (c) 2025 rtj.dev LLC.
|
||||
Licensed under the MIT License. See LICENSE file for details.
|
||||
"""
|
||||
|
||||
from .argument import Argument
|
||||
from .argument_action import ArgumentAction
|
||||
from .command_argument_parser import 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",
|
||||
]
|
116
falyx/parser/argument.py
Normal file
116
falyx/parser/argument.py
Normal file
@ -0,0 +1,116 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""argument.py"""
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from falyx.action.base_action import BaseAction
|
||||
from falyx.parser.argument_action import ArgumentAction
|
||||
|
||||
|
||||
@dataclass
|
||||
class Argument:
|
||||
"""
|
||||
Represents a command-line argument.
|
||||
|
||||
Attributes:
|
||||
flags (tuple[str, ...]): Short and long flags for the argument.
|
||||
dest (str): The destination name for the argument.
|
||||
action (ArgumentAction): The action to be taken when the argument is encountered.
|
||||
type (Any): The type of the argument (e.g., str, int, float) or a callable that converts the argument value.
|
||||
default (Any): The default value if the argument is not provided.
|
||||
choices (list[str] | None): A list of valid choices for the argument.
|
||||
required (bool): True if the argument is required, False otherwise.
|
||||
help (str): Help text for the argument.
|
||||
nargs (int | str | None): Number of arguments expected. Can be an int, '?', '*', '+', or None.
|
||||
positional (bool): True if the argument is positional (no leading - or -- in flags), False otherwise.
|
||||
resolver (BaseAction | None):
|
||||
An action object that resolves the argument, if applicable.
|
||||
lazy_resolver (bool): True if the resolver should be called lazily, False otherwise
|
||||
"""
|
||||
|
||||
flags: tuple[str, ...]
|
||||
dest: str
|
||||
action: ArgumentAction = ArgumentAction.STORE
|
||||
type: Any = str
|
||||
default: Any = None
|
||||
choices: list[str] | None = None
|
||||
required: bool = False
|
||||
help: str = ""
|
||||
nargs: int | str | None = None
|
||||
positional: bool = False
|
||||
resolver: BaseAction | None = None
|
||||
lazy_resolver: bool = False
|
||||
|
||||
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,
|
||||
ArgumentAction.ACTION,
|
||||
)
|
||||
and not self.positional
|
||||
):
|
||||
choice_text = self.dest.upper()
|
||||
elif self.action in (
|
||||
ArgumentAction.STORE,
|
||||
ArgumentAction.APPEND,
|
||||
ArgumentAction.EXTEND,
|
||||
ArgumentAction.ACTION,
|
||||
) 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,
|
||||
)
|
||||
)
|
28
falyx/parser/argument_action.py
Normal file
28
falyx/parser/argument_action.py
Normal file
@ -0,0 +1,28 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""argument_action.py"""
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
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"
|
||||
STORE_BOOL_OPTIONAL = "store_bool_optional"
|
||||
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
|
1035
falyx/parser/command_argument_parser.py
Normal file
1035
falyx/parser/command_argument_parser.py
Normal file
File diff suppressed because it is too large
Load Diff
15
falyx/parser/parser_types.py
Normal file
15
falyx/parser/parser_types.py
Normal file
@ -0,0 +1,15 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""parser_types.py"""
|
||||
from typing import Any
|
||||
|
||||
|
||||
def true_none(value: Any) -> bool | None:
|
||||
if value is None:
|
||||
return None
|
||||
return True
|
||||
|
||||
|
||||
def false_none(value: Any) -> bool | None:
|
||||
if value is None:
|
||||
return None
|
||||
return False
|
383
falyx/parser/parsers.py
Normal file
383
falyx/parser/parsers.py
Normal file
@ -0,0 +1,383 @@
|
||||
# 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:
|
||||
"""
|
||||
Construct the root-level ArgumentParser for the Falyx CLI.
|
||||
|
||||
This parser handles global arguments shared across subcommands and can serve
|
||||
as the base parser for the Falyx CLI or standalone applications. It includes
|
||||
options for verbosity, debug logging, and version output.
|
||||
|
||||
Args:
|
||||
prog (str | None): Name of the program (e.g., 'falyx').
|
||||
usage (str | None): Optional custom usage string.
|
||||
description (str | None): Description shown in the CLI help.
|
||||
epilog (str | None): Message displayed at the end of help output.
|
||||
parents (Sequence[ArgumentParser] | None): Optional parent parsers.
|
||||
prefix_chars (str): Characters to denote optional arguments (default: "-").
|
||||
fromfile_prefix_chars (str | None): Prefix to indicate argument file input.
|
||||
argument_default (Any): Global default value for arguments.
|
||||
conflict_handler (str): Strategy to resolve conflicting argument names.
|
||||
add_help (bool): Whether to include help (`-h/--help`) in this parser.
|
||||
allow_abbrev (bool): Allow abbreviated long options.
|
||||
exit_on_error (bool): Exit immediately on error or raise an exception.
|
||||
|
||||
Returns:
|
||||
ArgumentParser: The root parser with global options attached.
|
||||
|
||||
Notes:
|
||||
```
|
||||
Includes the following arguments:
|
||||
--never-prompt : Run in non-interactive mode.
|
||||
-v / --verbose : Enable debug logging.
|
||||
--debug-hooks : Enable hook lifecycle debug logs.
|
||||
--version : Print the Falyx version.
|
||||
```
|
||||
"""
|
||||
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=f"Enable debug logging for {prog}."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug-hooks",
|
||||
action="store_true",
|
||||
help="Enable default lifecycle debug logging",
|
||||
)
|
||||
parser.add_argument("--version", action="store_true", help=f"Show {prog} 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 object for registering Falyx CLI subcommands.
|
||||
|
||||
This function adds a `subparsers` block to the given root parser, enabling
|
||||
structured subcommands such as `run`, `run-all`, `preview`, etc.
|
||||
|
||||
Args:
|
||||
parser (ArgumentParser): The root parser to attach the subparsers to.
|
||||
title (str): Title used in help output to group subcommands.
|
||||
description (str | None): Optional text describing the group of subcommands.
|
||||
|
||||
Returns:
|
||||
_SubParsersAction: The subparsers object that can be used to add new CLI subcommands.
|
||||
|
||||
Raises:
|
||||
TypeError: If `parser` is not an instance of `ArgumentParser`.
|
||||
|
||||
Example:
|
||||
```python
|
||||
>>> parser = get_root_parser()
|
||||
>>> subparsers = get_subparsers(parser, title="Available Commands")
|
||||
>>> subparsers.add_parser("run", help="Run a Falyx command")
|
||||
```
|
||||
"""
|
||||
if not isinstance(parser, ArgumentParser):
|
||||
raise TypeError("parser must be an instance of ArgumentParser")
|
||||
subparsers = parser.add_subparsers(
|
||||
title=title,
|
||||
description=description,
|
||||
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:
|
||||
"""
|
||||
Create and return the full suite of argument parsers used by the Falyx CLI.
|
||||
|
||||
This function builds the root parser and all subcommand parsers used for structured
|
||||
CLI workflows in Falyx. It supports standard subcommands including `run`, `run-all`,
|
||||
`preview`, `list`, and `version`, and integrates with registered `Command` objects
|
||||
to populate dynamic help and usage documentation.
|
||||
|
||||
Args:
|
||||
prog (str | None): Program name to display in help and usage messages.
|
||||
usage (str | None): Optional usage message to override the default.
|
||||
description (str | None): Description for the CLI root parser.
|
||||
epilog (str | None): Epilog message shown after the help text.
|
||||
parents (Sequence[ArgumentParser] | None): Optional parent parsers.
|
||||
prefix_chars (str): Characters that prefix optional arguments.
|
||||
fromfile_prefix_chars (str | None): Prefix character for reading args from file.
|
||||
argument_default (Any): Default value for arguments if not specified.
|
||||
conflict_handler (str): Strategy for resolving conflicting arguments.
|
||||
add_help (bool): Whether to add the `-h/--help` option to the root parser.
|
||||
allow_abbrev (bool): Whether to allow abbreviated long options.
|
||||
exit_on_error (bool): Whether the parser exits on error or raises.
|
||||
commands (dict[str, Command] | None): Optional dictionary of registered commands
|
||||
to populate help and subcommand descriptions dynamically.
|
||||
root_parser (ArgumentParser | None): Custom root parser to use instead of building one.
|
||||
subparsers (_SubParsersAction | None): Optional existing subparser object to extend.
|
||||
|
||||
Returns:
|
||||
FalyxParsers: A structured container of all parsers, including `run`, `run-all`,
|
||||
`preview`, `list`, `version`, and the root parser.
|
||||
|
||||
Raises:
|
||||
TypeError: If `root_parser` is not an instance of ArgumentParser or
|
||||
`subparsers` is not an instance of _SubParsersAction.
|
||||
|
||||
Example:
|
||||
```python
|
||||
>>> parsers = get_arg_parsers(commands=my_command_dict)
|
||||
>>> args = parsers.root.parse_args()
|
||||
```
|
||||
|
||||
Notes:
|
||||
- This function integrates dynamic command usage and descriptions if the
|
||||
`commands` argument is provided.
|
||||
- The `run` parser supports additional options for retry logic and confirmation
|
||||
prompts.
|
||||
- The `run-all` parser executes all commands matching a tag.
|
||||
- Use `falyx run ?[COMMAND]` from the CLI to preview a command.
|
||||
"""
|
||||
if epilog is None:
|
||||
epilog = f"Tip: Use '{prog} run ?[COMMAND]' to preview any command from 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:
|
||||
if prog == "falyx":
|
||||
subparsers = get_subparsers(
|
||||
parser,
|
||||
title="Falyx Commands",
|
||||
description="Available commands for the Falyx CLI.",
|
||||
)
|
||||
else:
|
||||
subparsers = get_subparsers(parser, title="subcommands", description=None)
|
||||
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.help_text or command.description
|
||||
run_description.append(f"{' '*24}{command_description}")
|
||||
run_epilog = (
|
||||
f"Tip: Use '{prog} 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=f"Show {prog} version")
|
||||
|
||||
return FalyxParsers(
|
||||
root=parser,
|
||||
subparsers=subparsers,
|
||||
run=run_parser,
|
||||
run_all=run_all_parser,
|
||||
preview=preview_parser,
|
||||
list=list_parser,
|
||||
version=version_parser,
|
||||
)
|
81
falyx/parser/signature.py
Normal file
81
falyx/parser/signature.py
Normal file
@ -0,0 +1,81 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
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
|
98
falyx/parser/utils.py
Normal file
98
falyx/parser/utils.py
Normal file
@ -0,0 +1,98 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
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_action import BaseAction
|
||||
from falyx.logger import logger
|
||||
from falyx.parser.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", "t", "1", "yes", "on"}:
|
||||
return True
|
||||
elif value in {"false", "f", "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)
|
||||
try:
|
||||
coerced_value = base_type(value)
|
||||
return enum_type(coerced_value)
|
||||
except (ValueError, TypeError):
|
||||
values = [str(enum.value) for enum in enum_type]
|
||||
raise ValueError(f"'{value}' should be one of {{{', '.join(values)}}}") from None
|
||||
|
||||
|
||||
def coerce_value(value: str, target_type: type) -> Any:
|
||||
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}")
|
||||
|
||||
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
|
179
falyx/parsers.py
179
falyx/parsers.py
@ -1,179 +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, _SubParsersAction
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any, Sequence
|
||||
|
||||
|
||||
@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_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,
|
||||
) -> FalyxParsers:
|
||||
"""Returns the argument parser for the CLI."""
|
||||
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")
|
||||
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(
|
||||
"--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_group.add_argument(
|
||||
"command_args",
|
||||
nargs=REMAINDER,
|
||||
help="Arguments to pass to the command (if applicable)",
|
||||
)
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
@ -2,14 +2,16 @@
|
||||
"""protocols.py"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Awaitable, Protocol, runtime_checkable
|
||||
from typing import Any, Awaitable, Callable, Protocol, runtime_checkable
|
||||
|
||||
from falyx.action.action import BaseAction
|
||||
from falyx.action.base_action import BaseAction
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ActionFactoryProtocol(Protocol):
|
||||
async def __call__(self, *args: Any, **kwargs: Any) -> Awaitable[BaseAction]: ...
|
||||
async def __call__(
|
||||
self, *args: Any, **kwargs: Any
|
||||
) -> Callable[..., Awaitable[BaseAction]]: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
|
@ -53,7 +53,7 @@ class RetryHandler:
|
||||
self.policy.delay = delay
|
||||
self.policy.backoff = backoff
|
||||
self.policy.jitter = jitter
|
||||
logger.info("🔄 Retry policy enabled: %s", self.policy)
|
||||
logger.info("Retry policy enabled: %s", self.policy)
|
||||
|
||||
async def retry_on_error(self, context: ExecutionContext) -> None:
|
||||
from falyx.action import Action
|
||||
@ -67,21 +67,21 @@ class RetryHandler:
|
||||
last_error = error
|
||||
|
||||
if not target:
|
||||
logger.warning("[%s] ⚠️ No action target. Cannot retry.", name)
|
||||
logger.warning("[%s] No action target. Cannot retry.", name)
|
||||
return None
|
||||
|
||||
if not isinstance(target, Action):
|
||||
logger.warning(
|
||||
"[%s] ❌ RetryHandler only supports only supports Action objects.", name
|
||||
"[%s] RetryHandler only supports only supports Action objects.", name
|
||||
)
|
||||
return None
|
||||
|
||||
if not getattr(target, "is_retryable", False):
|
||||
logger.warning("[%s] ❌ Not retryable.", name)
|
||||
logger.warning("[%s] Not retryable.", name)
|
||||
return None
|
||||
|
||||
if not self.policy.enabled:
|
||||
logger.warning("[%s] ❌ Retry policy is disabled.", name)
|
||||
logger.warning("[%s] Retry policy is disabled.", name)
|
||||
return None
|
||||
|
||||
while retries_done < self.policy.max_retries:
|
||||
@ -92,7 +92,7 @@ class RetryHandler:
|
||||
sleep_delay += random.uniform(-self.policy.jitter, self.policy.jitter)
|
||||
|
||||
logger.info(
|
||||
"[%s] 🔄 Retrying (%s/%s) in %ss due to '%s'...",
|
||||
"[%s] Retrying (%s/%s) in %ss due to '%s'...",
|
||||
name,
|
||||
retries_done,
|
||||
self.policy.max_retries,
|
||||
@ -104,13 +104,13 @@ class RetryHandler:
|
||||
result = await target.action(*context.args, **context.kwargs)
|
||||
context.result = result
|
||||
context.exception = None
|
||||
logger.info("[%s] ✅ Retry succeeded on attempt %s.", name, retries_done)
|
||||
logger.info("[%s] Retry succeeded on attempt %s.", name, retries_done)
|
||||
return None
|
||||
except Exception as retry_error:
|
||||
last_error = retry_error
|
||||
current_delay *= self.policy.backoff
|
||||
logger.warning(
|
||||
"[%s] ⚠️ Retry attempt %s/%s failed due to '%s'.",
|
||||
"[%s] Retry attempt %s/%s failed due to '%s'.",
|
||||
name,
|
||||
retries_done,
|
||||
self.policy.max_retries,
|
||||
@ -118,4 +118,4 @@ class RetryHandler:
|
||||
)
|
||||
|
||||
context.exception = last_error
|
||||
logger.error("[%s] ❌ All %s retries failed.", name, self.policy.max_retries)
|
||||
logger.error("[%s] All %s retries failed.", name, self.policy.max_retries)
|
||||
|
@ -1,6 +1,7 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""retry_utils.py"""
|
||||
from falyx.action.action import Action, BaseAction
|
||||
from falyx.action.action import Action
|
||||
from falyx.action.base_action import BaseAction
|
||||
from falyx.hook_manager import HookType
|
||||
from falyx.retry import RetryHandler, RetryPolicy
|
||||
|
||||
|
@ -5,13 +5,13 @@ from typing import Any, Callable, KeysView, Sequence
|
||||
|
||||
from prompt_toolkit import PromptSession
|
||||
from rich import box
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
from rich.table import Table
|
||||
|
||||
from falyx.console import console
|
||||
from falyx.themes import OneColors
|
||||
from falyx.utils import chunks
|
||||
from falyx.validators import int_range_validator, key_validator
|
||||
from falyx.utils import CaseInsensitiveDict, chunks
|
||||
from falyx.validators import MultiIndexValidator, MultiKeyValidator
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -32,6 +32,62 @@ class SelectionOption:
|
||||
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,
|
||||
*,
|
||||
@ -211,23 +267,37 @@ async def prompt_for_index(
|
||||
*,
|
||||
min_index: int = 0,
|
||||
default_selection: str = "",
|
||||
console: Console | None = None,
|
||||
prompt_session: PromptSession | None = None,
|
||||
prompt_message: str = "Select an option > ",
|
||||
show_table: bool = True,
|
||||
):
|
||||
number_selections: int | str = 1,
|
||||
separator: str = ",",
|
||||
allow_duplicates: bool = False,
|
||||
cancel_key: str = "",
|
||||
) -> int | list[int]:
|
||||
prompt_session = prompt_session or PromptSession()
|
||||
console = console or Console(color_system="auto")
|
||||
|
||||
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),
|
||||
validator=MultiIndexValidator(
|
||||
min_index,
|
||||
max_index,
|
||||
number_selections,
|
||||
separator,
|
||||
allow_duplicates,
|
||||
cancel_key,
|
||||
),
|
||||
default=default_selection,
|
||||
)
|
||||
return int(selection)
|
||||
|
||||
if selection.strip() == cancel_key:
|
||||
return int(cancel_key)
|
||||
if isinstance(number_selections, int) and number_selections == 1:
|
||||
return int(selection.strip())
|
||||
return [int(index.strip()) for index in selection.strip().split(separator)]
|
||||
|
||||
|
||||
async def prompt_for_selection(
|
||||
@ -235,35 +305,46 @@ async def prompt_for_selection(
|
||||
table: Table,
|
||||
*,
|
||||
default_selection: str = "",
|
||||
console: Console | None = None,
|
||||
prompt_session: PromptSession | None = None,
|
||||
prompt_message: str = "Select an option > ",
|
||||
show_table: bool = True,
|
||||
) -> str:
|
||||
number_selections: int | str = 1,
|
||||
separator: str = ",",
|
||||
allow_duplicates: bool = False,
|
||||
cancel_key: str = "",
|
||||
) -> str | list[str]:
|
||||
"""Prompt the user to select a key from a set of options. Return the selected key."""
|
||||
prompt_session = prompt_session or PromptSession()
|
||||
console = console or Console(color_system="auto")
|
||||
|
||||
if show_table:
|
||||
console.print(table, justify="center")
|
||||
|
||||
selected = await prompt_session.prompt_async(
|
||||
message=prompt_message,
|
||||
validator=key_validator(keys),
|
||||
validator=MultiKeyValidator(
|
||||
keys, number_selections, separator, allow_duplicates, cancel_key
|
||||
),
|
||||
default=default_selection,
|
||||
)
|
||||
|
||||
return selected
|
||||
if selected.strip() == cancel_key:
|
||||
return cancel_key
|
||||
if isinstance(number_selections, int) and number_selections == 1:
|
||||
return selected.strip()
|
||||
return [key.strip() for key in selected.strip().split(separator)]
|
||||
|
||||
|
||||
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 = "",
|
||||
number_selections: int | str = 1,
|
||||
separator: str = ",",
|
||||
allow_duplicates: bool = False,
|
||||
cancel_key: str = "",
|
||||
columns: int = 4,
|
||||
caption: str = "",
|
||||
box_style: box.Box = box.SIMPLE,
|
||||
@ -276,7 +357,7 @@ async def select_value_from_list(
|
||||
title_style: str = "",
|
||||
caption_style: str = "",
|
||||
highlight: bool = False,
|
||||
):
|
||||
) -> str | list[str]:
|
||||
"""Prompt for a selection. Return the selected item."""
|
||||
table = render_selection_indexed_table(
|
||||
title=title,
|
||||
@ -295,17 +376,21 @@ async def select_value_from_list(
|
||||
highlight=highlight,
|
||||
)
|
||||
prompt_session = prompt_session or PromptSession()
|
||||
console = console or Console(color_system="auto")
|
||||
|
||||
selection_index = await prompt_for_index(
|
||||
len(selections) - 1,
|
||||
table,
|
||||
default_selection=default_selection,
|
||||
console=console,
|
||||
prompt_session=prompt_session,
|
||||
prompt_message=prompt_message,
|
||||
number_selections=number_selections,
|
||||
separator=separator,
|
||||
allow_duplicates=allow_duplicates,
|
||||
cancel_key=cancel_key,
|
||||
)
|
||||
|
||||
if isinstance(selection_index, list):
|
||||
return [selections[i] for i in selection_index]
|
||||
return selections[selection_index]
|
||||
|
||||
|
||||
@ -313,14 +398,16 @@ 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:
|
||||
number_selections: int | str = 1,
|
||||
separator: str = ",",
|
||||
allow_duplicates: bool = False,
|
||||
cancel_key: str = "",
|
||||
) -> str | list[str]:
|
||||
"""Prompt for a key from a dict, returns the key."""
|
||||
prompt_session = prompt_session or PromptSession()
|
||||
console = console or Console(color_system="auto")
|
||||
|
||||
console.print(table, justify="center")
|
||||
|
||||
@ -328,9 +415,12 @@ async def select_key_from_dict(
|
||||
selections.keys(),
|
||||
table,
|
||||
default_selection=default_selection,
|
||||
console=console,
|
||||
prompt_session=prompt_session,
|
||||
prompt_message=prompt_message,
|
||||
number_selections=number_selections,
|
||||
separator=separator,
|
||||
allow_duplicates=allow_duplicates,
|
||||
cancel_key=cancel_key,
|
||||
)
|
||||
|
||||
|
||||
@ -338,14 +428,16 @@ 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:
|
||||
number_selections: int | str = 1,
|
||||
separator: str = ",",
|
||||
allow_duplicates: bool = False,
|
||||
cancel_key: str = "",
|
||||
) -> Any | list[Any]:
|
||||
"""Prompt for a key from a dict, but return the value."""
|
||||
prompt_session = prompt_session or PromptSession()
|
||||
console = console or Console(color_system="auto")
|
||||
|
||||
console.print(table, justify="center")
|
||||
|
||||
@ -353,11 +445,16 @@ async def select_value_from_dict(
|
||||
selections.keys(),
|
||||
table,
|
||||
default_selection=default_selection,
|
||||
console=console,
|
||||
prompt_session=prompt_session,
|
||||
prompt_message=prompt_message,
|
||||
number_selections=number_selections,
|
||||
separator=separator,
|
||||
allow_duplicates=allow_duplicates,
|
||||
cancel_key=cancel_key,
|
||||
)
|
||||
|
||||
if isinstance(selection_key, list):
|
||||
return [selections[key].value for key in selection_key]
|
||||
return selections[selection_key].value
|
||||
|
||||
|
||||
@ -365,11 +462,14 @@ 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 = "",
|
||||
):
|
||||
number_selections: int | str = 1,
|
||||
separator: str = ",",
|
||||
allow_duplicates: bool = False,
|
||||
cancel_key: str = "",
|
||||
) -> Any | list[Any]:
|
||||
"""Prompt for a key from a dict, but return the value."""
|
||||
table = render_selection_dict_table(
|
||||
title,
|
||||
@ -379,8 +479,11 @@ async def get_selection_from_dict_menu(
|
||||
return await select_value_from_dict(
|
||||
selections=selections,
|
||||
table=table,
|
||||
console=console,
|
||||
prompt_session=prompt_session,
|
||||
prompt_message=prompt_message,
|
||||
default_selection=default_selection,
|
||||
number_selections=number_selections,
|
||||
separator=separator,
|
||||
allow_duplicates=allow_duplicates,
|
||||
cancel_key=cancel_key,
|
||||
)
|
||||
|
@ -10,6 +10,13 @@ class FlowSignal(BaseException):
|
||||
"""
|
||||
|
||||
|
||||
class BreakChainSignal(FlowSignal):
|
||||
"""Raised to break the current action chain and return to the previous context."""
|
||||
|
||||
def __init__(self, message: str = "Break chain signal received."):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class QuitSignal(FlowSignal):
|
||||
"""Raised to signal an immediate exit from the CLI framework."""
|
||||
|
||||
|
@ -184,7 +184,7 @@ def setup_logging(
|
||||
console_handler.setLevel(console_log_level)
|
||||
root.addHandler(console_handler)
|
||||
|
||||
file_handler = logging.FileHandler(log_filename)
|
||||
file_handler = logging.FileHandler(log_filename, "a", "UTF-8")
|
||||
file_handler.setLevel(file_log_level)
|
||||
if json_log_to_file:
|
||||
file_handler.setFormatter(
|
||||
|
@ -2,7 +2,7 @@
|
||||
"""validators.py"""
|
||||
from typing import KeysView, Sequence
|
||||
|
||||
from prompt_toolkit.validation import Validator
|
||||
from prompt_toolkit.validation import ValidationError, Validator
|
||||
|
||||
|
||||
def int_range_validator(minimum: int, maximum: int) -> Validator:
|
||||
@ -44,4 +44,119 @@ def yes_no_validator() -> Validator:
|
||||
return False
|
||||
return True
|
||||
|
||||
return Validator.from_callable(validate, error_message="Enter 'Y' or 'n'.")
|
||||
return Validator.from_callable(validate, error_message="Enter 'Y', 'y' or 'N', 'n'.")
|
||||
|
||||
|
||||
def words_validator(
|
||||
keys: Sequence[str] | KeysView[str], error_message: str | None = None
|
||||
) -> Validator:
|
||||
"""Validator for specific word inputs."""
|
||||
|
||||
def validate(text: str) -> bool:
|
||||
if text.upper() not in [key.upper() for key in keys]:
|
||||
return False
|
||||
return True
|
||||
|
||||
if error_message is None:
|
||||
error_message = f"Invalid input. Choices: {{{', '.join(keys)}}}."
|
||||
|
||||
return Validator.from_callable(validate, error_message=error_message)
|
||||
|
||||
|
||||
def word_validator(word: str) -> Validator:
|
||||
"""Validator for specific word inputs."""
|
||||
|
||||
def validate(text: str) -> bool:
|
||||
if text.upper().strip() == "N":
|
||||
return True
|
||||
return text.upper().strip() == word.upper()
|
||||
|
||||
return Validator.from_callable(validate, error_message=f"Enter '{word}' or 'N', 'n'.")
|
||||
|
||||
|
||||
class MultiIndexValidator(Validator):
|
||||
def __init__(
|
||||
self,
|
||||
minimum: int,
|
||||
maximum: int,
|
||||
number_selections: int | str,
|
||||
separator: str,
|
||||
allow_duplicates: bool,
|
||||
cancel_key: str,
|
||||
) -> None:
|
||||
self.minimum = minimum
|
||||
self.maximum = maximum
|
||||
self.number_selections = number_selections
|
||||
self.separator = separator
|
||||
self.allow_duplicates = allow_duplicates
|
||||
self.cancel_key = cancel_key
|
||||
super().__init__()
|
||||
|
||||
def validate(self, document):
|
||||
selections = [
|
||||
index.strip() for index in document.text.strip().split(self.separator)
|
||||
]
|
||||
if not selections or selections == [""]:
|
||||
raise ValidationError(message="Select at least 1 item.")
|
||||
if self.cancel_key in selections and len(selections) == 1:
|
||||
return
|
||||
elif self.cancel_key in selections:
|
||||
raise ValidationError(message="Cancel key must be selected alone.")
|
||||
for selection in selections:
|
||||
try:
|
||||
index = int(selection)
|
||||
if not self.minimum <= index <= self.maximum:
|
||||
raise ValidationError(
|
||||
message=f"Invalid selection: {selection}. Select a number between {self.minimum} and {self.maximum}."
|
||||
)
|
||||
except ValueError:
|
||||
raise ValidationError(
|
||||
message=f"Invalid selection: {selection}. Select a number between {self.minimum} and {self.maximum}."
|
||||
)
|
||||
if not self.allow_duplicates and selections.count(selection) > 1:
|
||||
raise ValidationError(message=f"Duplicate selection: {selection}")
|
||||
if isinstance(self.number_selections, int):
|
||||
if self.number_selections == 1 and len(selections) > 1:
|
||||
raise ValidationError(message="Invalid selection. Select only 1 item.")
|
||||
if len(selections) != self.number_selections:
|
||||
raise ValidationError(
|
||||
message=f"Select exactly {self.number_selections} items separated by '{self.separator}'"
|
||||
)
|
||||
|
||||
|
||||
class MultiKeyValidator(Validator):
|
||||
def __init__(
|
||||
self,
|
||||
keys: Sequence[str] | KeysView[str],
|
||||
number_selections: int | str,
|
||||
separator: str,
|
||||
allow_duplicates: bool,
|
||||
cancel_key: str,
|
||||
) -> None:
|
||||
self.keys = keys
|
||||
self.separator = separator
|
||||
self.number_selections = number_selections
|
||||
self.allow_duplicates = allow_duplicates
|
||||
self.cancel_key = cancel_key
|
||||
super().__init__()
|
||||
|
||||
def validate(self, document):
|
||||
selections = [key.strip() for key in document.text.strip().split(self.separator)]
|
||||
if not selections or selections == [""]:
|
||||
raise ValidationError(message="Select at least 1 item.")
|
||||
if self.cancel_key in selections and len(selections) == 1:
|
||||
return
|
||||
elif self.cancel_key in selections:
|
||||
raise ValidationError(message="Cancel key must be selected alone.")
|
||||
for selection in selections:
|
||||
if selection.upper() not in [key.upper() for key in self.keys]:
|
||||
raise ValidationError(message=f"Invalid selection: {selection}")
|
||||
if not self.allow_duplicates and selections.count(selection) > 1:
|
||||
raise ValidationError(message=f"Duplicate selection: {selection}")
|
||||
if isinstance(self.number_selections, int):
|
||||
if self.number_selections == 1 and len(selections) > 1:
|
||||
raise ValidationError(message="Invalid selection. Select only 1 item.")
|
||||
if len(selections) != self.number_selections:
|
||||
raise ValidationError(
|
||||
message=f"Select exactly {self.number_selections} items separated by '{self.separator}'"
|
||||
)
|
||||
|
@ -1 +1 @@
|
||||
__version__ = "0.1.28"
|
||||
__version__ = "0.1.62"
|
||||
|
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "falyx"
|
||||
version = "0.1.28"
|
||||
version = "0.1.62"
|
||||
description = "Reliable and introspectable async CLI action framework."
|
||||
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
|
||||
license = "MIT"
|
||||
@ -16,6 +16,7 @@ python-json-logger = "^3.3.0"
|
||||
toml = "^0.10"
|
||||
pyyaml = "^6.0"
|
||||
aiohttp = "^3.11"
|
||||
python-dateutil = "^2.8"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = "^8.3.5"
|
||||
@ -26,6 +27,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"
|
||||
mkdocs = "^1.6.1"
|
||||
mkdocs-material = "^9.6.14"
|
||||
mkdocstrings = {extras = ["python"], version = "^0.29.1"}
|
||||
mike = "^2.1.3"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
falyx = "falyx.__main__:main"
|
||||
|
@ -38,13 +38,14 @@ async def test_action_async_callable():
|
||||
action = Action("test_action", async_callable)
|
||||
result = await action()
|
||||
assert result == "Hello, World!"
|
||||
print(action)
|
||||
assert (
|
||||
str(action)
|
||||
== "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False)"
|
||||
== "Action(name='test_action', action=async_callable, retry=False, rollback=False)"
|
||||
)
|
||||
assert (
|
||||
repr(action)
|
||||
== "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False)"
|
||||
== "Action(name='test_action', action=async_callable, retry=False, rollback=False)"
|
||||
)
|
||||
|
||||
|
||||
|
23
tests/test_actions/test_action_factory.py
Normal file
23
tests/test_actions/test_action_factory.py
Normal file
@ -0,0 +1,23 @@
|
||||
import pytest
|
||||
|
||||
from falyx.action import Action, ActionFactory, 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 = ActionFactory(name="test_action", factory=make_chain, args=("test_value",))
|
||||
|
||||
result = await action()
|
||||
|
||||
assert result == ["test_value_1", "test_value_2"]
|
@ -1,7 +1,7 @@
|
||||
# test_command.py
|
||||
import pytest
|
||||
|
||||
from falyx.action import Action, ActionGroup, BaseIOAction, ChainedAction
|
||||
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
|
||||
@ -50,108 +50,13 @@ 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)
|
||||
print(cmd)
|
||||
assert (
|
||||
str(cmd)
|
||||
== "Command(key='TEST', description='Test Command' action='Action(name='test_action', action=dummy_action, args=(), kwargs={}, retry=False)')"
|
||||
== "Command(key='TEST', description='Test Command' action='Action(name='test_action', action=dummy_action, retry=False, rollback=False)')"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"action_factory, expected_requires_input",
|
||||
[
|
||||
(lambda: Action(name="normal", action=dummy_action), False),
|
||||
(lambda: DummyInputAction(name="io"), True),
|
||||
(
|
||||
lambda: ChainedAction(name="chain", actions=[DummyInputAction(name="io")]),
|
||||
True,
|
||||
),
|
||||
(
|
||||
lambda: ActionGroup(name="group", actions=[DummyInputAction(name="io")]),
|
||||
True,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_command_requires_input_detection(action_factory, expected_requires_input):
|
||||
action = action_factory()
|
||||
cmd = Command(key="TEST", description="Test Command", action=action)
|
||||
|
||||
assert cmd.requires_input == expected_requires_input
|
||||
if expected_requires_input:
|
||||
assert cmd.hidden is True
|
||||
else:
|
||||
assert cmd.hidden is False
|
||||
|
||||
|
||||
def test_requires_input_flag_detected_for_baseioaction():
|
||||
"""Command should automatically detect requires_input=True for BaseIOAction."""
|
||||
cmd = Command(
|
||||
key="X",
|
||||
description="Echo input",
|
||||
action=DummyInputAction(name="dummy"),
|
||||
)
|
||||
assert cmd.requires_input is True
|
||||
assert cmd.hidden is True
|
||||
|
||||
|
||||
def test_requires_input_manual_override():
|
||||
"""Command manually set requires_input=False should not auto-hide."""
|
||||
cmd = Command(
|
||||
key="Y",
|
||||
description="Custom input command",
|
||||
action=DummyInputAction(name="dummy"),
|
||||
requires_input=False,
|
||||
)
|
||||
assert cmd.requires_input is False
|
||||
assert cmd.hidden is False
|
||||
|
||||
|
||||
def test_default_command_does_not_require_input():
|
||||
"""Normal Command without IO Action should not require input."""
|
||||
cmd = Command(
|
||||
key="Z",
|
||||
description="Simple action",
|
||||
action=lambda: 42,
|
||||
)
|
||||
assert cmd.requires_input is False
|
||||
assert cmd.hidden is False
|
||||
|
||||
|
||||
def test_chain_requires_input():
|
||||
"""If first action in a chain requires input, the command should require input."""
|
||||
chain = ChainedAction(
|
||||
name="ChainWithInput",
|
||||
actions=[
|
||||
DummyInputAction(name="dummy"),
|
||||
Action(name="action1", action=lambda: 1),
|
||||
],
|
||||
)
|
||||
cmd = Command(
|
||||
key="A",
|
||||
description="Chain with input",
|
||||
action=chain,
|
||||
)
|
||||
assert cmd.requires_input is True
|
||||
assert cmd.hidden is True
|
||||
|
||||
|
||||
def test_group_requires_input():
|
||||
"""If any action in a group requires input, the command should require input."""
|
||||
group = ActionGroup(
|
||||
name="GroupWithInput",
|
||||
actions=[
|
||||
Action(name="action1", action=lambda: 1),
|
||||
DummyInputAction(name="dummy"),
|
||||
],
|
||||
)
|
||||
cmd = Command(
|
||||
key="B",
|
||||
description="Group with input",
|
||||
action=group,
|
||||
)
|
||||
assert cmd.requires_input is True
|
||||
assert cmd.hidden is True
|
||||
|
||||
|
||||
def test_enable_retry():
|
||||
"""Command should enable retry if action is an Action and retry is set to True."""
|
||||
cmd = Command(
|
||||
@ -193,13 +98,17 @@ def test_enable_retry_not_action():
|
||||
cmd = Command(
|
||||
key="C",
|
||||
description="Retry action",
|
||||
action=DummyInputAction,
|
||||
action=DummyInputAction(
|
||||
name="dummy_input_action",
|
||||
),
|
||||
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)
|
||||
assert "'DummyInputAction' object has no attribute 'retry_policy'" in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
def test_chain_retry_all():
|
||||
@ -229,13 +138,17 @@ def test_chain_retry_all_not_base_action():
|
||||
cmd = Command(
|
||||
key="E",
|
||||
description="Chain with retry",
|
||||
action=DummyInputAction,
|
||||
action=DummyInputAction(
|
||||
name="dummy_input_action",
|
||||
),
|
||||
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)
|
||||
assert "'DummyInputAction' object has no attribute 'retry_policy'" in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
@ -1,102 +1,113 @@
|
||||
import pytest
|
||||
|
||||
from falyx.argparse import ArgumentAction, CommandArgumentParser
|
||||
from falyx.exceptions import CommandArgumentError
|
||||
from falyx.parser import ArgumentAction, CommandArgumentParser
|
||||
from falyx.signals import HelpSignal
|
||||
|
||||
|
||||
def build_parser_and_parse(args, config):
|
||||
async def build_parser_and_parse(args, config):
|
||||
cap = CommandArgumentParser()
|
||||
config(cap)
|
||||
return cap.parse_args(args)
|
||||
return await cap.parse_args(args)
|
||||
|
||||
|
||||
def test_none():
|
||||
@pytest.mark.asyncio
|
||||
async def test_none():
|
||||
def config(parser):
|
||||
parser.add_argument("--foo", type=str)
|
||||
|
||||
parsed = build_parser_and_parse(None, config)
|
||||
parsed = await build_parser_and_parse(None, config)
|
||||
assert parsed["foo"] is None
|
||||
|
||||
|
||||
def test_append_multiple_flags():
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_multiple_flags():
|
||||
def config(parser):
|
||||
parser.add_argument("--tag", action=ArgumentAction.APPEND, type=str)
|
||||
|
||||
parsed = build_parser_and_parse(["--tag", "a", "--tag", "b", "--tag", "c"], config)
|
||||
parsed = await build_parser_and_parse(
|
||||
["--tag", "a", "--tag", "b", "--tag", "c"], config
|
||||
)
|
||||
assert parsed["tag"] == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_positional_nargs_plus_and_single():
|
||||
@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 = build_parser_and_parse(["a", "b", "c", "prod"], config)
|
||||
parsed = await build_parser_and_parse(["a", "b", "c", "prod"], config)
|
||||
assert parsed["files"] == ["a", "b", "c"]
|
||||
assert parsed["mode"] == "prod"
|
||||
|
||||
|
||||
def test_type_validation_failure():
|
||||
@pytest.mark.asyncio
|
||||
async def test_type_validation_failure():
|
||||
def config(parser):
|
||||
parser.add_argument("--count", type=int)
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
build_parser_and_parse(["--count", "abc"], config)
|
||||
await build_parser_and_parse(["--count", "abc"], config)
|
||||
|
||||
|
||||
def test_required_field_missing():
|
||||
@pytest.mark.asyncio
|
||||
async def test_required_field_missing():
|
||||
def config(parser):
|
||||
parser.add_argument("--env", type=str, required=True)
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
build_parser_and_parse([], config)
|
||||
await build_parser_and_parse([], config)
|
||||
|
||||
|
||||
def test_choices_enforced():
|
||||
@pytest.mark.asyncio
|
||||
async def test_choices_enforced():
|
||||
def config(parser):
|
||||
parser.add_argument("--mode", choices=["dev", "prod"])
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
build_parser_and_parse(["--mode", "staging"], config)
|
||||
await build_parser_and_parse(["--mode", "staging"], config)
|
||||
|
||||
|
||||
def test_boolean_flags():
|
||||
@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 = build_parser_and_parse(["--debug", "--no-debug"], config)
|
||||
parsed = await build_parser_and_parse(["--debug", "--no-debug"], config)
|
||||
assert parsed["debug"] is True
|
||||
assert parsed["no_debug"] is False
|
||||
parsed = build_parser_and_parse([], config)
|
||||
print(parsed)
|
||||
parsed = await build_parser_and_parse([], config)
|
||||
assert parsed["debug"] is False
|
||||
assert parsed["no_debug"] is True
|
||||
|
||||
|
||||
def test_count_action():
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_action():
|
||||
def config(parser):
|
||||
parser.add_argument("-v", action=ArgumentAction.COUNT)
|
||||
|
||||
parsed = build_parser_and_parse(["-v", "-v", "-v"], config)
|
||||
parsed = await build_parser_and_parse(["-v", "-v", "-v"], config)
|
||||
assert parsed["v"] == 3
|
||||
|
||||
|
||||
def test_nargs_star():
|
||||
@pytest.mark.asyncio
|
||||
async def test_nargs_star():
|
||||
def config(parser):
|
||||
parser.add_argument("args", nargs="*", type=str)
|
||||
|
||||
parsed = build_parser_and_parse(["one", "two", "three"], config)
|
||||
parsed = await build_parser_and_parse(["one", "two", "three"], config)
|
||||
assert parsed["args"] == ["one", "two", "three"]
|
||||
|
||||
|
||||
def test_flag_and_positional_mix():
|
||||
@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 = build_parser_and_parse(["--env", "prod", "build", "test"], config)
|
||||
parsed = await build_parser_and_parse(["--env", "prod", "build", "test"], config)
|
||||
assert parsed["env"] == "prod"
|
||||
assert parsed["tasks"] == ["build", "test"]
|
||||
|
||||
@ -134,7 +145,7 @@ def test_add_argument_multiple_optional_flags_same_dest():
|
||||
parser.add_argument("-f", "--falyx")
|
||||
arg = parser._arguments[-1]
|
||||
assert arg.dest == "falyx"
|
||||
assert arg.flags == ["-f", "--falyx"]
|
||||
assert arg.flags == ("-f", "--falyx")
|
||||
|
||||
|
||||
def test_add_argument_flag_dest_conflict():
|
||||
@ -165,7 +176,7 @@ def test_add_argument_multiple_flags_custom_dest():
|
||||
parser.add_argument("-f", "--falyx", "--test", dest="falyx")
|
||||
arg = parser._arguments[-1]
|
||||
assert arg.dest == "falyx"
|
||||
assert arg.flags == ["-f", "--falyx", "--test"]
|
||||
assert arg.flags == ("-f", "--falyx", "--test")
|
||||
|
||||
|
||||
def test_add_argument_multiple_flags_dest():
|
||||
@ -175,7 +186,7 @@ def test_add_argument_multiple_flags_dest():
|
||||
parser.add_argument("-f", "--falyx", "--test")
|
||||
arg = parser._arguments[-1]
|
||||
assert arg.dest == "falyx"
|
||||
assert arg.flags == ["-f", "--falyx", "--test"]
|
||||
assert arg.flags == ("-f", "--falyx", "--test")
|
||||
|
||||
|
||||
def test_add_argument_single_flag_dest():
|
||||
@ -185,7 +196,7 @@ def test_add_argument_single_flag_dest():
|
||||
parser.add_argument("-f")
|
||||
arg = parser._arguments[-1]
|
||||
assert arg.dest == "f"
|
||||
assert arg.flags == ["-f"]
|
||||
assert arg.flags == ("-f",)
|
||||
|
||||
|
||||
def test_add_argument_bad_dest():
|
||||
@ -257,7 +268,7 @@ def test_add_argument_default_value():
|
||||
parser.add_argument("--falyx", default="default_value")
|
||||
arg = parser._arguments[-1]
|
||||
assert arg.dest == "falyx"
|
||||
assert arg.flags == ["--falyx"]
|
||||
assert arg.flags == ("--falyx",)
|
||||
assert arg.default == "default_value"
|
||||
|
||||
|
||||
@ -297,20 +308,21 @@ def test_add_argument_default_not_in_choices():
|
||||
parser.add_argument("--falyx", choices=["a", "b"], default="c")
|
||||
|
||||
|
||||
def test_add_argument_choices():
|
||||
@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.flags == ("--falyx",)
|
||||
assert arg.choices == ["a", "b", "c"]
|
||||
|
||||
args = parser.parse_args(["--falyx", "a"])
|
||||
args = await parser.parse_args(["--falyx", "a"])
|
||||
assert args["falyx"] == "a"
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.parse_args(["--falyx", "d"])
|
||||
await parser.parse_args(["--falyx", "d"])
|
||||
|
||||
|
||||
def test_add_argument_choices_invalid():
|
||||
@ -333,26 +345,28 @@ def test_add_argument_choices_invalid():
|
||||
def test_add_argument_bad_nargs():
|
||||
parser = CommandArgumentParser()
|
||||
|
||||
# ❌ Invalid nargs value
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument("--falyx", nargs="invalid")
|
||||
|
||||
# ❌ Invalid nargs type
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument("--falyx", nargs=123)
|
||||
parser.add_argument("--foo", nargs="123")
|
||||
|
||||
# ❌ Invalid nargs type
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument("--falyx", nargs=None)
|
||||
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()
|
||||
# ✅ Valid nargs value
|
||||
parser.add_argument("--falyx", nargs=2)
|
||||
arg = parser._arguments[-1]
|
||||
assert arg.dest == "falyx"
|
||||
assert arg.flags == ["--falyx"]
|
||||
assert arg.flags == ("--falyx",)
|
||||
assert arg.nargs == 2
|
||||
|
||||
|
||||
@ -377,56 +391,63 @@ def test_get_argument():
|
||||
parser.add_argument("--falyx", type=str, default="default_value")
|
||||
arg = parser.get_argument("falyx")
|
||||
assert arg.dest == "falyx"
|
||||
assert arg.flags == ["--falyx"]
|
||||
assert arg.flags == ("--falyx",)
|
||||
assert arg.default == "default_value"
|
||||
|
||||
|
||||
def test_parse_args_nargs():
|
||||
@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 = parser.parse_args(["a", "b", "c"])
|
||||
|
||||
args = await parser.parse_args(["a", "b", "c", "--action"])
|
||||
assert args["files"] == ["a", "b"]
|
||||
assert args["mode"] == "c"
|
||||
args = await parser.parse_args(["--action", "a", "b", "c"])
|
||||
assert args["files"] == ["a", "b"]
|
||||
assert args["mode"] == "c"
|
||||
|
||||
|
||||
def test_parse_args_nargs_plus():
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_args_nargs_plus():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("files", nargs="+", type=str)
|
||||
|
||||
args = parser.parse_args(["a", "b", "c"])
|
||||
args = await parser.parse_args(["a", "b", "c"])
|
||||
assert args["files"] == ["a", "b", "c"]
|
||||
|
||||
args = parser.parse_args(["a"])
|
||||
args = await parser.parse_args(["a"])
|
||||
assert args["files"] == ["a"]
|
||||
|
||||
|
||||
def test_parse_args_flagged_nargs_plus():
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_args_flagged_nargs_plus():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("--files", nargs="+", type=str)
|
||||
|
||||
args = parser.parse_args(["--files", "a", "b", "c"])
|
||||
args = await parser.parse_args(["--files", "a", "b", "c"])
|
||||
assert args["files"] == ["a", "b", "c"]
|
||||
|
||||
args = parser.parse_args(["--files", "a"])
|
||||
args = await parser.parse_args(["--files", "a"])
|
||||
print(args)
|
||||
assert args["files"] == ["a"]
|
||||
|
||||
args = parser.parse_args([])
|
||||
args = await parser.parse_args([])
|
||||
assert args["files"] == []
|
||||
|
||||
|
||||
def test_parse_args_numbered_nargs():
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_args_numbered_nargs():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("files", nargs=2, type=str)
|
||||
|
||||
args = parser.parse_args(["a", "b"])
|
||||
args = await parser.parse_args(["a", "b"])
|
||||
assert args["files"] == ["a", "b"]
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
args = parser.parse_args(["a"])
|
||||
args = await parser.parse_args(["a"])
|
||||
print(args)
|
||||
|
||||
|
||||
@ -436,48 +457,53 @@ def test_parse_args_nargs_zero():
|
||||
parser.add_argument("files", nargs=0, type=str)
|
||||
|
||||
|
||||
def test_parse_args_nargs_more_than_expected():
|
||||
@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):
|
||||
parser.parse_args(["a", "b", "c", "d"])
|
||||
await parser.parse_args(["a", "b", "c", "d"])
|
||||
|
||||
|
||||
def test_parse_args_nargs_one_or_none():
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_args_nargs_one_or_none():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("files", nargs="?", type=str)
|
||||
|
||||
args = parser.parse_args(["a"])
|
||||
args = await parser.parse_args(["a"])
|
||||
assert args["files"] == "a"
|
||||
|
||||
args = parser.parse_args([])
|
||||
args = await parser.parse_args([])
|
||||
assert args["files"] is None
|
||||
|
||||
|
||||
def test_parse_args_nargs_positional():
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_args_nargs_positional():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("files", nargs="*", type=str)
|
||||
|
||||
args = parser.parse_args(["a", "b", "c"])
|
||||
args = await parser.parse_args(["a", "b", "c"])
|
||||
assert args["files"] == ["a", "b", "c"]
|
||||
|
||||
args = parser.parse_args([])
|
||||
args = await parser.parse_args([])
|
||||
assert args["files"] == []
|
||||
|
||||
|
||||
def test_parse_args_nargs_positional_plus():
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_args_nargs_positional_plus():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("files", nargs="+", type=str)
|
||||
|
||||
args = parser.parse_args(["a", "b", "c"])
|
||||
args = await parser.parse_args(["a", "b", "c"])
|
||||
assert args["files"] == ["a", "b", "c"]
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
args = parser.parse_args([])
|
||||
args = await parser.parse_args([])
|
||||
|
||||
|
||||
def test_parse_args_nargs_multiple_positional():
|
||||
@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)
|
||||
@ -485,7 +511,7 @@ def test_parse_args_nargs_multiple_positional():
|
||||
parser.add_argument("target", nargs="*")
|
||||
parser.add_argument("extra", nargs="+")
|
||||
|
||||
args = parser.parse_args(["a", "b", "c", "d", "e"])
|
||||
args = await parser.parse_args(["a", "b", "c", "d", "e"])
|
||||
assert args["files"] == ["a", "b", "c"]
|
||||
assert args["mode"] == "d"
|
||||
assert args["action"] == []
|
||||
@ -493,186 +519,311 @@ def test_parse_args_nargs_multiple_positional():
|
||||
assert args["extra"] == ["e"]
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.parse_args([])
|
||||
await parser.parse_args([])
|
||||
|
||||
|
||||
def test_parse_args_nargs_invalid_positional_arguments():
|
||||
@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):
|
||||
parser.parse_args(["1", "2", "c", "d"])
|
||||
await parser.parse_args(["1", "2", "c", "d"])
|
||||
|
||||
|
||||
def test_parse_args_append():
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_args_append():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int)
|
||||
|
||||
args = parser.parse_args(["--numbers", "1", "--numbers", "2", "--numbers", "3"])
|
||||
args = await parser.parse_args(["--numbers", "1", "--numbers", "2", "--numbers", "3"])
|
||||
assert args["numbers"] == [1, 2, 3]
|
||||
|
||||
args = parser.parse_args(["--numbers", "1"])
|
||||
args = await parser.parse_args(["--numbers", "1"])
|
||||
assert args["numbers"] == [1]
|
||||
|
||||
args = parser.parse_args([])
|
||||
args = await parser.parse_args([])
|
||||
assert args["numbers"] == []
|
||||
|
||||
|
||||
def test_parse_args_nargs_append():
|
||||
@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 = parser.parse_args(["1", "2", "3", "--mode", "numbers", "4", "5"])
|
||||
assert args["numbers"] == [[1, 2, 3], [4, 5]]
|
||||
|
||||
args = parser.parse_args(["1"])
|
||||
args = await parser.parse_args(["1"])
|
||||
assert args["numbers"] == [[1]]
|
||||
|
||||
args = parser.parse_args([])
|
||||
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"] == []
|
||||
|
||||
|
||||
def test_parse_args_append_flagged_invalid_type():
|
||||
@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):
|
||||
parser.parse_args(["--numbers", "a"])
|
||||
await parser.parse_args(["--numbers", "a"])
|
||||
|
||||
|
||||
def test_append_groups_nargs():
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_groups_nargs():
|
||||
cap = CommandArgumentParser()
|
||||
cap.add_argument("--item", action=ArgumentAction.APPEND, type=str, nargs=2)
|
||||
|
||||
parsed = cap.parse_args(["--item", "a", "b", "--item", "c", "d"])
|
||||
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"])
|
||||
|
||||
def test_extend_flattened():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extend_flattened():
|
||||
cap = CommandArgumentParser()
|
||||
cap.add_argument("--value", action=ArgumentAction.EXTEND, type=str)
|
||||
|
||||
parsed = cap.parse_args(["--value", "x", "--value", "y"])
|
||||
parsed = await cap.parse_args(["--value", "x", "--value", "y"])
|
||||
assert parsed["value"] == ["x", "y"]
|
||||
|
||||
|
||||
def test_parse_args_split_order():
|
||||
@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 = cap.parse_args_split(["1", "--x", "100", "2"])
|
||||
args, kwargs = await cap.parse_args_split(["1", "--x", "100", "2"])
|
||||
assert args == ("1", ["2"])
|
||||
assert kwargs == {"x": "100"}
|
||||
|
||||
|
||||
def test_help_signal_triggers():
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_signal_triggers():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("--foo")
|
||||
with pytest.raises(HelpSignal):
|
||||
parser.parse_args(["--help"])
|
||||
await parser.parse_args(["--help"])
|
||||
|
||||
|
||||
def test_empty_parser_defaults():
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_parser_defaults():
|
||||
parser = CommandArgumentParser()
|
||||
with pytest.raises(HelpSignal):
|
||||
parser.parse_args(["--help"])
|
||||
await parser.parse_args(["--help"])
|
||||
|
||||
|
||||
def test_extend_basic():
|
||||
@pytest.mark.asyncio
|
||||
async def test_extend_basic():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("--tag", action=ArgumentAction.EXTEND, type=str)
|
||||
|
||||
args = parser.parse_args(["--tag", "a", "--tag", "b", "--tag", "c"])
|
||||
args = await parser.parse_args(["--tag", "a", "--tag", "b", "--tag", "c"])
|
||||
assert args["tag"] == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_extend_nargs_2():
|
||||
@pytest.mark.asyncio
|
||||
async def test_extend_nargs_2():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("--pair", action=ArgumentAction.EXTEND, type=str, nargs=2)
|
||||
|
||||
args = parser.parse_args(["--pair", "a", "b", "--pair", "c", "d"])
|
||||
args = await parser.parse_args(["--pair", "a", "b", "--pair", "c", "d"])
|
||||
assert args["pair"] == ["a", "b", "c", "d"]
|
||||
|
||||
|
||||
def test_extend_nargs_star():
|
||||
@pytest.mark.asyncio
|
||||
async def test_extend_nargs_star():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("--files", action=ArgumentAction.EXTEND, type=str, nargs="*")
|
||||
|
||||
args = parser.parse_args(["--files", "x", "y", "z"])
|
||||
args = await parser.parse_args(["--files", "x", "y", "z"])
|
||||
assert args["files"] == ["x", "y", "z"]
|
||||
|
||||
args = parser.parse_args(["--files"])
|
||||
args = await parser.parse_args(["--files"])
|
||||
assert args["files"] == []
|
||||
|
||||
|
||||
def test_extend_nargs_plus():
|
||||
@pytest.mark.asyncio
|
||||
async def test_extend_nargs_plus():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("--inputs", action=ArgumentAction.EXTEND, type=int, nargs="+")
|
||||
|
||||
args = parser.parse_args(["--inputs", "1", "2", "3", "--inputs", "4"])
|
||||
args = await parser.parse_args(["--inputs", "1", "2", "3", "--inputs", "4"])
|
||||
assert args["inputs"] == [1, 2, 3, 4]
|
||||
|
||||
|
||||
def test_extend_invalid_type():
|
||||
@pytest.mark.asyncio
|
||||
async def test_extend_invalid_type():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("--nums", action=ArgumentAction.EXTEND, type=int)
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.parse_args(["--nums", "a"])
|
||||
await parser.parse_args(["--nums", "a"])
|
||||
|
||||
|
||||
def test_greedy_invalid_type():
|
||||
@pytest.mark.asyncio
|
||||
async def test_greedy_invalid_type():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("--nums", nargs="*", type=int)
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.parse_args(["--nums", "a"])
|
||||
await parser.parse_args(["--nums", "a"])
|
||||
|
||||
|
||||
def test_append_vs_extend_behavior():
|
||||
@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 = parser.parse_args(
|
||||
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"]
|
||||
|
||||
|
||||
def test_append_vs_extend_behavior_error():
|
||||
@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):
|
||||
parser.parse_args(["--x", "a", "b", "--x", "c", "d", "--y", "1", "2", "--y", "3"])
|
||||
await parser.parse_args(
|
||||
["--x", "a", "b", "--x", "c", "d", "--y", "1", "2", "--y", "3"]
|
||||
)
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.parse_args(["--x", "a", "b", "--x", "c", "--y", "1", "--y", "3", "4"])
|
||||
await parser.parse_args(
|
||||
["--x", "a", "b", "--x", "c", "--y", "1", "--y", "3", "4"]
|
||||
)
|
||||
|
||||
|
||||
def test_extend_positional():
|
||||
@pytest.mark.asyncio
|
||||
async def test_extend_positional():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("files", action=ArgumentAction.EXTEND, type=str, nargs="*")
|
||||
|
||||
args = parser.parse_args(["a", "b", "c"])
|
||||
args = await parser.parse_args(["a", "b", "c"])
|
||||
assert args["files"] == ["a", "b", "c"]
|
||||
|
||||
args = parser.parse_args([])
|
||||
args = await parser.parse_args([])
|
||||
assert args["files"] == []
|
||||
|
||||
|
||||
def test_extend_positional_nargs():
|
||||
@pytest.mark.asyncio
|
||||
async def test_extend_positional_nargs():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("files", action=ArgumentAction.EXTEND, type=str, nargs="+")
|
||||
|
||||
args = parser.parse_args(["a", "b", "c"])
|
||||
args = await parser.parse_args(["a", "b", "c"])
|
||||
assert args["files"] == ["a", "b", "c"]
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.parse_args([])
|
||||
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
|
||||
|
@ -5,7 +5,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from falyx.__main__ import bootstrap, find_falyx_config, get_falyx_parsers, run
|
||||
from falyx.__main__ import bootstrap, find_falyx_config, main
|
||||
|
||||
|
||||
def test_find_falyx_config():
|
||||
@ -50,63 +50,3 @@ def test_bootstrap_with_global_config():
|
||||
assert str(config_file.parent) in sys.path
|
||||
config_file.unlink()
|
||||
sys.path = sys_path_before
|
||||
|
||||
|
||||
def test_parse_args():
|
||||
"""Test if the parse_args function works correctly."""
|
||||
falyx_parsers = get_falyx_parsers()
|
||||
args = falyx_parsers.parse_args(["init", "test_project"])
|
||||
|
||||
assert args.command == "init"
|
||||
assert args.name == "test_project"
|
||||
|
||||
args = falyx_parsers.parse_args(["init-global"])
|
||||
assert args.command == "init-global"
|
||||
|
||||
|
||||
def test_run():
|
||||
"""Test if the run function works correctly."""
|
||||
falyx_parsers = get_falyx_parsers()
|
||||
args = falyx_parsers.parse_args(["init", "test_project"])
|
||||
run(args)
|
||||
assert args.command == "init"
|
||||
assert args.name == "test_project"
|
||||
# Check if the project directory was created
|
||||
assert Path("test_project").exists()
|
||||
# Clean up
|
||||
(Path("test_project") / "falyx.yaml").unlink()
|
||||
(Path("test_project") / "tasks.py").unlink()
|
||||
Path("test_project").rmdir()
|
||||
# Test init-global
|
||||
args = falyx_parsers.parse_args(["init-global"])
|
||||
run(args)
|
||||
# Check if the global config directory was created
|
||||
assert (Path.home() / ".config" / "falyx" / "falyx.yaml").exists()
|
||||
# Clean up
|
||||
(Path.home() / ".config" / "falyx" / "falyx.yaml").unlink()
|
||||
(Path.home() / ".config" / "falyx" / "tasks.py").unlink()
|
||||
(Path.home() / ".config" / "falyx").rmdir()
|
||||
|
||||
|
||||
def test_no_bootstrap():
|
||||
"""Test if the main function works correctly when no config file is found."""
|
||||
falyx_parsers = get_falyx_parsers()
|
||||
args = falyx_parsers.parse_args(["list"])
|
||||
assert run(args) is None
|
||||
# Check if the task was run
|
||||
assert args.command == "list"
|
||||
|
||||
|
||||
def test_run_test_project():
|
||||
"""Test if the main function works correctly with a test project."""
|
||||
falyx_parsers = get_falyx_parsers()
|
||||
args = falyx_parsers.parse_args(["init", "test_project"])
|
||||
run(args)
|
||||
|
||||
args = falyx_parsers.parse_args(["run", "B"])
|
||||
os.chdir("test_project")
|
||||
with pytest.raises(SystemExit):
|
||||
assert run(args) == "Build complete!"
|
||||
os.chdir("..")
|
||||
shutil.rmtree("test_project")
|
||||
assert not Path("test_project").exists()
|
||||
|
219
tests/test_parsers/test_action.py
Normal file
219
tests/test_parsers/test_action.py
Normal file
@ -0,0 +1,219 @@
|
||||
import pytest
|
||||
|
||||
from falyx.action import Action
|
||||
from falyx.exceptions import CommandArgumentError
|
||||
from falyx.parser 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"])
|
90
tests/test_parsers/test_argument.py
Normal file
90
tests/test_parsers/test_argument.py
Normal file
@ -0,0 +1,90 @@
|
||||
import pytest
|
||||
|
||||
from falyx.parser 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()
|
11
tests/test_parsers/test_argument_action.py
Normal file
11
tests/test_parsers/test_argument_action.py
Normal file
@ -0,0 +1,11 @@
|
||||
from falyx.parser import ArgumentAction
|
||||
|
||||
|
||||
def test_argument_action():
|
||||
action = ArgumentAction.APPEND
|
||||
assert action == ArgumentAction.APPEND
|
||||
assert action != ArgumentAction.STORE
|
||||
assert action != "invalid_action"
|
||||
assert action.value == "append"
|
||||
assert str(action) == "append"
|
||||
assert len(ArgumentAction.choices()) == 9
|
49
tests/test_parsers/test_basics.py
Normal file
49
tests/test_parsers/test_basics.py
Normal file
@ -0,0 +1,49 @@
|
||||
import pytest
|
||||
|
||||
from falyx.exceptions import CommandArgumentError
|
||||
from falyx.parser import CommandArgumentParser
|
||||
|
||||
|
||||
def test_str():
|
||||
"""Test the string representation of CommandArgumentParser."""
|
||||
parser = CommandArgumentParser()
|
||||
assert (
|
||||
str(parser)
|
||||
== "CommandArgumentParser(args=1, flags=2, keywords=2, positional=0, required=0)"
|
||||
)
|
||||
|
||||
parser.add_argument("test", action="store", help="Test argument")
|
||||
assert (
|
||||
str(parser)
|
||||
== "CommandArgumentParser(args=2, flags=3, keywords=2, positional=1, required=1)"
|
||||
)
|
||||
|
||||
parser.add_argument("-o", "--optional", action="store", help="Optional argument")
|
||||
assert (
|
||||
str(parser)
|
||||
== "CommandArgumentParser(args=3, flags=5, keywords=4, positional=1, required=1)"
|
||||
)
|
||||
|
||||
parser.add_argument("--flag", action="store", help="Flag argument", required=True)
|
||||
assert (
|
||||
str(parser)
|
||||
== "CommandArgumentParser(args=4, flags=6, keywords=5, positional=1, required=2)"
|
||||
)
|
||||
assert (
|
||||
repr(parser)
|
||||
== "CommandArgumentParser(args=4, flags=6, keywords=5, positional=1, required=2)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_positional_text_with_choices():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("path", choices=["a", "b"])
|
||||
args = await parser.parse_args(["a"])
|
||||
assert args["path"] == "a"
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args(["c"])
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args([])
|
153
tests/test_parsers/test_coerce_value.py
Normal file
153
tests/test_parsers/test_coerce_value.py
Normal file
@ -0,0 +1,153 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import pytest
|
||||
|
||||
from falyx.parser.utils import coerce_value
|
||||
|
||||
|
||||
# --- Tests ---
|
||||
@pytest.mark.parametrize(
|
||||
"value, target_type, expected",
|
||||
[
|
||||
("42", int, 42),
|
||||
("3.14", float, 3.14),
|
||||
("True", bool, True),
|
||||
("hello", str, "hello"),
|
||||
("", str, ""),
|
||||
("False", bool, False),
|
||||
],
|
||||
)
|
||||
def test_coerce_value_basic(value, target_type, expected):
|
||||
assert coerce_value(value, target_type) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, target_type, expected",
|
||||
[
|
||||
("42", int | float, 42),
|
||||
("3.14", int | float, 3.14),
|
||||
("hello", str | int, "hello"),
|
||||
("1", bool | str, True),
|
||||
],
|
||||
)
|
||||
def test_coerce_value_union_success(value, target_type, expected):
|
||||
assert coerce_value(value, target_type) == expected
|
||||
|
||||
|
||||
def test_coerce_value_union_failure():
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
coerce_value("abc", int | float)
|
||||
assert "could not be coerced" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_coerce_value_typing_union_equivalent():
|
||||
from typing import Union
|
||||
|
||||
assert coerce_value("123", Union[int, str]) == 123
|
||||
assert coerce_value("abc", Union[int, str]) == "abc"
|
||||
|
||||
|
||||
def test_coerce_value_edge_cases():
|
||||
# int -> raises
|
||||
with pytest.raises(ValueError):
|
||||
coerce_value("not-an-int", int | float)
|
||||
|
||||
# empty string with str fallback
|
||||
assert coerce_value("", int | str) == ""
|
||||
|
||||
# bool conversion
|
||||
assert coerce_value("False", bool | str) is False
|
||||
|
||||
|
||||
def test_coerce_value_enum():
|
||||
class Color(Enum):
|
||||
RED = "red"
|
||||
GREEN = "green"
|
||||
BLUE = "blue"
|
||||
|
||||
assert coerce_value("red", Color) == Color.RED
|
||||
assert coerce_value("green", Color) == Color.GREEN
|
||||
assert coerce_value("blue", Color) == Color.BLUE
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
coerce_value("yellow", Color) # Not a valid enum value
|
||||
|
||||
|
||||
def test_coerce_value_int_enum():
|
||||
class Status(Enum):
|
||||
SUCCESS = 0
|
||||
FAILURE = 1
|
||||
PENDING = 2
|
||||
|
||||
assert coerce_value("0", Status) == Status.SUCCESS
|
||||
assert coerce_value(1, Status) == Status.FAILURE
|
||||
assert coerce_value("PENDING", Status) == Status.PENDING
|
||||
assert coerce_value(Status.SUCCESS, Status) == Status.SUCCESS
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
coerce_value("3", Status)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
coerce_value(3, Status)
|
||||
|
||||
|
||||
class Mode(Enum):
|
||||
DEV = "dev"
|
||||
PROD = "prod"
|
||||
|
||||
|
||||
def test_literal_coercion():
|
||||
assert coerce_value("dev", Literal["dev", "prod"]) == "dev"
|
||||
try:
|
||||
coerce_value("staging", Literal["dev", "prod"])
|
||||
assert False
|
||||
except ValueError:
|
||||
assert True
|
||||
|
||||
|
||||
def test_enum_coercion():
|
||||
assert coerce_value("dev", Mode) == Mode.DEV
|
||||
assert coerce_value("DEV", Mode) == Mode.DEV
|
||||
try:
|
||||
coerce_value("staging", Mode)
|
||||
assert False
|
||||
except ValueError:
|
||||
assert True
|
||||
|
||||
|
||||
def test_union_coercion():
|
||||
assert coerce_value("123", int | str) == 123
|
||||
assert coerce_value("abc", int | str) == "abc"
|
||||
assert coerce_value("False", bool | str) is False
|
||||
|
||||
|
||||
def test_path_coercion():
|
||||
result = coerce_value("/tmp/test.txt", Path)
|
||||
assert isinstance(result, Path)
|
||||
assert str(result) == "/tmp/test.txt"
|
||||
|
||||
|
||||
def test_datetime_coercion():
|
||||
result = coerce_value("2023-10-01T13:00:00", datetime)
|
||||
assert isinstance(result, datetime)
|
||||
assert result.year == 2023 and result.month == 10
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
coerce_value("not-a-date", datetime)
|
||||
|
||||
|
||||
def test_bool_coercion():
|
||||
assert coerce_value("true", bool) is True
|
||||
assert coerce_value("False", bool) is False
|
||||
assert coerce_value("0", bool) is False
|
||||
assert coerce_value("", bool) is False
|
||||
assert coerce_value("1", bool) is True
|
||||
assert coerce_value("yes", bool) is True
|
||||
assert coerce_value("no", bool) is False
|
||||
assert coerce_value("on", bool) is True
|
||||
assert coerce_value("off", bool) is False
|
||||
assert coerce_value(True, bool) is True
|
||||
assert coerce_value(False, bool) is False
|
40
tests/test_parsers/test_multiple_positional.py
Normal file
40
tests/test_parsers/test_multiple_positional.py
Normal file
@ -0,0 +1,40 @@
|
||||
import pytest
|
||||
|
||||
from falyx.parser import CommandArgumentParser
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_positional():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("files", nargs="+")
|
||||
parser.add_argument("mode", choices=["edit", "view"])
|
||||
|
||||
args = await parser.parse_args(["a", "b", "c", "edit"])
|
||||
assert args["files"] == ["a", "b", "c"]
|
||||
assert args["mode"] == "edit"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_positional_with_default():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("files", nargs="+")
|
||||
parser.add_argument("mode", choices=["edit", "view"], default="edit")
|
||||
|
||||
args = await parser.parse_args(["a", "b", "c"])
|
||||
assert args["files"] == ["a", "b", "c"]
|
||||
assert args["mode"] == "edit"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_positional_with_double_default():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("files", nargs="+", default=["a", "b", "c"])
|
||||
parser.add_argument("mode", choices=["edit", "view"], default="edit")
|
||||
|
||||
args = await parser.parse_args()
|
||||
assert args["files"] == ["a", "b", "c"]
|
||||
assert args["mode"] == "edit"
|
||||
|
||||
args = await parser.parse_args(["a", "b"])
|
||||
assert args["files"] == ["a", "b"]
|
||||
assert args["mode"] == "edit"
|
56
tests/test_parsers/test_nargs.py
Normal file
56
tests/test_parsers/test_nargs.py
Normal file
@ -0,0 +1,56 @@
|
||||
import pytest
|
||||
|
||||
from falyx.exceptions import CommandArgumentError
|
||||
from falyx.parser import ArgumentAction, CommandArgumentParser
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nargs():
|
||||
"""Test the nargs argument for command-line arguments."""
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument(
|
||||
"-a",
|
||||
"--alpha",
|
||||
action=ArgumentAction.STORE,
|
||||
nargs=2,
|
||||
help="Alpha option with two arguments",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-b",
|
||||
"--beta",
|
||||
action=ArgumentAction.STORE,
|
||||
nargs="+",
|
||||
help="Beta option with one or more arguments",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--charlie",
|
||||
action=ArgumentAction.STORE,
|
||||
nargs="*",
|
||||
help="Charlie option with zero or more arguments",
|
||||
)
|
||||
|
||||
# Test valid cases
|
||||
args = await parser.parse_args(["-a", "value1", "value2"])
|
||||
assert args["alpha"] == ["value1", "value2"]
|
||||
|
||||
args = await parser.parse_args(["-b", "value1", "value2", "value3"])
|
||||
assert args["beta"] == ["value1", "value2", "value3"]
|
||||
|
||||
args = await parser.parse_args(["-c", "value1", "value2"])
|
||||
assert args["charlie"] == ["value1", "value2"]
|
||||
|
||||
args = await parser.parse_args(["-c"])
|
||||
assert args["charlie"] == []
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args(["-a", "value1"])
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args(["-a"])
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args(["-a", "value1", "value2", "value3"])
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args(["-b"])
|
26
tests/test_parsers/test_negative_numbers.py
Normal file
26
tests/test_parsers/test_negative_numbers.py
Normal file
@ -0,0 +1,26 @@
|
||||
import pytest
|
||||
|
||||
from falyx.exceptions import CommandArgumentError
|
||||
from falyx.parser import CommandArgumentParser
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_negative_integer():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("--number", type=int, required=True, help="A negative integer")
|
||||
args = await parser.parse_args(["--number", "-42"])
|
||||
assert args["number"] == -42
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_negative_float():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("--value", type=float, required=True, help="A negative float")
|
||||
args = await parser.parse_args(["--value", "-3.14"])
|
||||
assert args["value"] == -3.14
|
||||
|
||||
|
||||
def test_parse_number_flag():
|
||||
parser = CommandArgumentParser()
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument("-1", type=int, required=True, help="A negative number flag")
|
128
tests/test_parsers/test_posix_bundling.py
Normal file
128
tests/test_parsers/test_posix_bundling.py
Normal file
@ -0,0 +1,128 @@
|
||||
import pytest
|
||||
|
||||
from falyx.exceptions import CommandArgumentError
|
||||
from falyx.parser import ArgumentAction, CommandArgumentParser
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_posix_bundling():
|
||||
"""Test the bundling of short options in the POSIX style."""
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument(
|
||||
"-a", "--alpha", action=ArgumentAction.STORE_FALSE, help="Alpha option"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-b", "--beta", action=ArgumentAction.STORE_TRUE, help="Beta option"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c", "--charlie", action=ArgumentAction.STORE_TRUE, help="Charlie option"
|
||||
)
|
||||
|
||||
# Test valid bundling
|
||||
args = await parser.parse_args(["-abc"])
|
||||
assert args["alpha"] is False
|
||||
assert args["beta"] is True
|
||||
assert args["charlie"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_posix_bundling_last_has_value():
|
||||
"""Test the bundling of short options in the POSIX style with last option having a value."""
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument(
|
||||
"-a", "--alpha", action=ArgumentAction.STORE_TRUE, help="Alpha option"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-b", "--beta", action=ArgumentAction.STORE_TRUE, help="Beta option"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c", "--charlie", action=ArgumentAction.STORE, help="Charlie option"
|
||||
)
|
||||
|
||||
# Test valid bundling with last option having a value
|
||||
args = await parser.parse_args(["-abc", "value"])
|
||||
assert args["alpha"] is True
|
||||
assert args["beta"] is True
|
||||
assert args["charlie"] == "value"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_posix_bundling_invalid():
|
||||
"""Test the bundling of short options in the POSIX style with invalid cases."""
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument(
|
||||
"-a", "--alpha", action=ArgumentAction.STORE_FALSE, help="Alpha option"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-b", "--beta", action=ArgumentAction.STORE_TRUE, help="Beta option"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c", "--charlie", action=ArgumentAction.STORE, help="Charlie option"
|
||||
)
|
||||
|
||||
# Test invalid bundling
|
||||
args = await parser.parse_args(["-abc", "value"])
|
||||
assert args["alpha"] is False
|
||||
assert args["beta"] is True
|
||||
assert args["charlie"] == "value"
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args(["-a", "value"])
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args(["-b", "value"])
|
||||
|
||||
args = await parser.parse_args(["-c", "value"])
|
||||
assert args["alpha"] is True
|
||||
assert args["beta"] is False
|
||||
assert args["charlie"] == "value"
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args(["-cab", "value"])
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args(["-a", "-b", "value"])
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args(["-dbc", "value"])
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
args = await parser.parse_args(["-c"])
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args(["-abc"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_posix_bundling_fuzz():
|
||||
"""Test the bundling of short options in the POSIX style with fuzzing."""
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument(
|
||||
"-a", "--alpha", action=ArgumentAction.STORE_FALSE, help="Alpha option"
|
||||
)
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args(["-"])
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args(["--"])
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args(["--=value"])
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args(["--flag="])
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args(["-a=b"])
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args(["---"])
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args(["-a", "-b", "-c"])
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args(["-a", "--", "-b", "-c"])
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args(["-a", "--flag", "-b", "-c"])
|
83
tests/test_parsers/test_store_bool_optional.py
Normal file
83
tests/test_parsers/test_store_bool_optional.py
Normal file
@ -0,0 +1,83 @@
|
||||
import pytest
|
||||
|
||||
from falyx.exceptions import CommandArgumentError
|
||||
from falyx.parser import ArgumentAction, CommandArgumentParser
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_bool_optional_true():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
action=ArgumentAction.STORE_BOOL_OPTIONAL,
|
||||
help="Enable debug mode.",
|
||||
)
|
||||
args = await parser.parse_args(["--debug"])
|
||||
assert args["debug"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_bool_optional_false():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
action=ArgumentAction.STORE_BOOL_OPTIONAL,
|
||||
help="Enable debug mode.",
|
||||
)
|
||||
args = await parser.parse_args(["--no-debug"])
|
||||
assert args["debug"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_bool_optional_default_none():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
action=ArgumentAction.STORE_BOOL_OPTIONAL,
|
||||
help="Enable debug mode.",
|
||||
)
|
||||
args = await parser.parse_args([])
|
||||
assert args["debug"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_bool_optional_flag_order():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action=ArgumentAction.STORE_BOOL_OPTIONAL,
|
||||
help="Run without making changes.",
|
||||
)
|
||||
args = await parser.parse_args(["--dry-run"])
|
||||
assert args["dry_run"] is True
|
||||
args = await parser.parse_args(["--no-dry-run"])
|
||||
assert args["dry_run"] is False
|
||||
|
||||
|
||||
def test_store_bool_optional_requires_long_flag():
|
||||
parser = CommandArgumentParser()
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument(
|
||||
"-d", action=ArgumentAction.STORE_BOOL_OPTIONAL, help="Invalid"
|
||||
)
|
||||
|
||||
|
||||
def test_store_bool_optional_disallows_multiple_flags():
|
||||
parser = CommandArgumentParser()
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument("--debug", "-d", action=ArgumentAction.STORE_BOOL_OPTIONAL)
|
||||
|
||||
|
||||
def test_store_bool_optional_duplicate_dest():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
action=ArgumentAction.STORE_BOOL_OPTIONAL,
|
||||
help="Enable debug mode.",
|
||||
)
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
action=ArgumentAction.STORE_TRUE,
|
||||
help="Conflicting debug option.",
|
||||
)
|
@ -1,6 +1,7 @@
|
||||
import pytest
|
||||
|
||||
from falyx import Action, Falyx
|
||||
from falyx import Falyx
|
||||
from falyx.action import Action
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
Reference in New Issue
Block a user