Add ArgumentAction.ACTION, support POSIX bundling in CAP, Move all Actions to their own file
This commit is contained in:
parent
429b434566
commit
fb1ffbe9f6
|
@ -1,6 +1,6 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from falyx import Action, ActionGroup, ChainedAction
|
from falyx.action import Action, ActionGroup, ChainedAction
|
||||||
|
|
||||||
|
|
||||||
# Actions can be defined as synchronous functions
|
# Actions can be defined as synchronous functions
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from falyx import Action, ActionGroup, Command, Falyx
|
from falyx import Falyx
|
||||||
|
from falyx.action import Action, ActionGroup
|
||||||
|
|
||||||
|
|
||||||
# Define a shared async function
|
# Define a shared async function
|
||||||
|
@ -19,10 +20,11 @@ action3 = Action("say_hello_3", action=say_hello)
|
||||||
# Combine into an ActionGroup
|
# Combine into an ActionGroup
|
||||||
group = ActionGroup(name="greet_group", actions=[action1, action2, action3])
|
group = ActionGroup(name="greet_group", actions=[action1, action2, action3])
|
||||||
|
|
||||||
# Create the Command with auto_args=True
|
flx = Falyx("Test Group")
|
||||||
cmd = Command(
|
flx.add_command(
|
||||||
key="G",
|
key="G",
|
||||||
description="Greet someone with multiple variations.",
|
description="Greet someone with multiple variations.",
|
||||||
|
aliases=["greet", "hello"],
|
||||||
action=group,
|
action=group,
|
||||||
arg_metadata={
|
arg_metadata={
|
||||||
"name": {
|
"name": {
|
||||||
|
@ -33,7 +35,4 @@ cmd = Command(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
flx = Falyx("Test Group")
|
|
||||||
flx.add_command_from_command(cmd)
|
|
||||||
asyncio.run(flx.run())
|
asyncio.run(flx.run())
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from falyx import Action, ChainedAction, Falyx
|
from falyx import Falyx
|
||||||
|
from falyx.action import Action, ChainedAction
|
||||||
from falyx.utils import setup_logging
|
from falyx.utils import setup_logging
|
||||||
|
|
||||||
setup_logging()
|
setup_logging()
|
||||||
|
|
|
@ -2,8 +2,8 @@ import asyncio
|
||||||
|
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
from falyx import ActionGroup, Falyx
|
from falyx import Falyx
|
||||||
from falyx.action import HTTPAction
|
from falyx.action import ActionGroup, HTTPAction
|
||||||
from falyx.hooks import ResultReporter
|
from falyx.hooks import ResultReporter
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from falyx import Action, ActionGroup, ChainedAction
|
|
||||||
from falyx import ExecutionRegistry as er
|
from falyx import ExecutionRegistry as er
|
||||||
from falyx import ProcessAction
|
from falyx.action import Action, ActionGroup, ChainedAction, ProcessAction
|
||||||
from falyx.retry import RetryHandler, RetryPolicy
|
from falyx.retry import RetryHandler, RetryPolicy
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
from falyx import Falyx, ProcessAction
|
from falyx import Falyx
|
||||||
|
from falyx.action import ProcessAction
|
||||||
from falyx.themes import NordColors as nc
|
from falyx.themes import NordColors as nc
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from falyx import Action, Falyx
|
from falyx import Falyx
|
||||||
|
from falyx.action import Action
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from falyx import Action, ChainedAction, Falyx
|
from falyx import Falyx
|
||||||
from falyx.action import ShellAction
|
from falyx.action import Action, ChainedAction, ShellAction
|
||||||
from falyx.hooks import ResultReporter
|
from falyx.hooks import ResultReporter
|
||||||
from falyx.utils import setup_logging
|
from falyx.utils import setup_logging
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import random
|
import random
|
||||||
|
|
||||||
from falyx import Action, ChainedAction, Falyx
|
from falyx import Falyx
|
||||||
|
from falyx.action import Action, ChainedAction
|
||||||
from falyx.utils import setup_logging
|
from falyx.utils import setup_logging
|
||||||
|
|
||||||
setup_logging()
|
setup_logging()
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import random
|
import random
|
||||||
|
|
||||||
from falyx import Action, ChainedAction, Falyx
|
from falyx import Falyx
|
||||||
|
from falyx.action import Action, ChainedAction
|
||||||
from falyx.utils import setup_logging
|
from falyx.utils import setup_logging
|
||||||
|
|
||||||
setup_logging()
|
setup_logging()
|
||||||
|
|
|
@ -7,23 +7,12 @@ Licensed under the MIT License. See LICENSE file for details.
|
||||||
|
|
||||||
import logging
|
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 .execution_registry import ExecutionRegistry
|
||||||
from .falyx import Falyx
|
from .falyx import Falyx
|
||||||
|
|
||||||
logger = logging.getLogger("falyx")
|
logger = logging.getLogger("falyx")
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Action",
|
|
||||||
"ChainedAction",
|
|
||||||
"ActionGroup",
|
|
||||||
"ProcessAction",
|
|
||||||
"Falyx",
|
"Falyx",
|
||||||
"Command",
|
|
||||||
"ExecutionContext",
|
|
||||||
"SharedContext",
|
|
||||||
"ExecutionRegistry",
|
"ExecutionRegistry",
|
||||||
"HookType",
|
|
||||||
]
|
]
|
||||||
|
|
|
@ -5,19 +5,17 @@ Copyright (c) 2025 rtj.dev LLC.
|
||||||
Licensed under the MIT License. See LICENSE file for details.
|
Licensed under the MIT License. See LICENSE file for details.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .action import (
|
from .action import Action
|
||||||
Action,
|
|
||||||
ActionGroup,
|
|
||||||
BaseAction,
|
|
||||||
ChainedAction,
|
|
||||||
FallbackAction,
|
|
||||||
LiteralInputAction,
|
|
||||||
ProcessAction,
|
|
||||||
)
|
|
||||||
from .action_factory import ActionFactoryAction
|
from .action_factory import ActionFactoryAction
|
||||||
|
from .action_group import ActionGroup
|
||||||
|
from .base import BaseAction
|
||||||
|
from .chained_action import ChainedAction
|
||||||
|
from .fallback_action import FallbackAction
|
||||||
from .http_action import HTTPAction
|
from .http_action import HTTPAction
|
||||||
from .io_action import BaseIOAction, ShellAction
|
from .io_action import BaseIOAction, ShellAction
|
||||||
|
from .literal_input_action import LiteralInputAction
|
||||||
from .menu_action import MenuAction
|
from .menu_action import MenuAction
|
||||||
|
from .process_action import ProcessAction
|
||||||
from .prompt_menu_action import PromptMenuAction
|
from .prompt_menu_action import PromptMenuAction
|
||||||
from .select_file_action import SelectFileAction
|
from .select_file_action import SelectFileAction
|
||||||
from .selection_action import SelectionAction
|
from .selection_action import SelectionAction
|
||||||
|
|
|
@ -1,170 +1,21 @@
|
||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
"""action.py
|
"""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 __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, Callable
|
||||||
|
|
||||||
from rich.console import Console
|
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
from falyx.context import ExecutionContext, SharedContext
|
from falyx.action.base import BaseAction
|
||||||
from falyx.debug import register_debug_hooks
|
from falyx.context import ExecutionContext
|
||||||
from falyx.exceptions import EmptyChainError
|
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
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.logger import logger
|
||||||
from falyx.options_manager import OptionsManager
|
|
||||||
from falyx.parsers.utils import same_argument_definitions
|
|
||||||
from falyx.retry import RetryHandler, RetryPolicy
|
from falyx.retry import RetryHandler, RetryPolicy
|
||||||
from falyx.themes import OneColors
|
from falyx.themes import OneColors
|
||||||
from falyx.utils import ensure_async
|
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').
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
*,
|
|
||||||
hooks: HookManager | None = None,
|
|
||||||
inject_last_result: bool = False,
|
|
||||||
inject_into: str = "last_result",
|
|
||||||
never_prompt: bool = False,
|
|
||||||
logging_hooks: bool = False,
|
|
||||||
) -> None:
|
|
||||||
self.name = name
|
|
||||||
self.hooks = hooks or HookManager()
|
|
||||||
self.is_retryable: bool = False
|
|
||||||
self.shared_context: SharedContext | None = None
|
|
||||||
self.inject_last_result: bool = inject_last_result
|
|
||||||
self.inject_into: str = inject_into
|
|
||||||
self._never_prompt: bool = never_prompt
|
|
||||||
self._skip_in_chain: bool = False
|
|
||||||
self.console = Console(color_system="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")
|
|
||||||
|
|
||||||
@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)
|
|
||||||
|
|
||||||
|
|
||||||
class Action(BaseAction):
|
class Action(BaseAction):
|
||||||
"""
|
"""
|
||||||
Action wraps a simple function or coroutine into a standard executable unit.
|
Action wraps a simple function or coroutine into a standard executable unit.
|
||||||
|
@ -309,574 +160,3 @@ class Action(BaseAction):
|
||||||
f"args={self.args!r}, kwargs={self.kwargs!r}, "
|
f"args={self.args!r}, kwargs={self.kwargs!r}, "
|
||||||
f"retry={self.retry_policy.enabled})"
|
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)
|
|
||||||
|
|
||||||
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
|
|
||||||
if self.actions:
|
|
||||||
return self.actions[0].get_infer_target()
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
def _clear_args(self):
|
|
||||||
return (), {}
|
|
||||||
|
|
||||||
async def _run(self, *args, **kwargs) -> list[Any]:
|
|
||||||
if not self.actions:
|
|
||||||
raise EmptyChainError(f"[{self.name}] No actions to execute.")
|
|
||||||
|
|
||||||
shared_context = SharedContext(name=self.name, action=self)
|
|
||||||
if self.shared_context:
|
|
||||||
shared_context.add_result(self.shared_context.last_result())
|
|
||||||
updated_kwargs = self._maybe_inject_last_result(kwargs)
|
|
||||||
context = ExecutionContext(
|
|
||||||
name=self.name,
|
|
||||||
args=args,
|
|
||||||
kwargs=updated_kwargs,
|
|
||||||
action=self,
|
|
||||||
extra={"results": [], "rollback_stack": []},
|
|
||||||
shared_context=shared_context,
|
|
||||||
)
|
|
||||||
context.start_timer()
|
|
||||||
try:
|
|
||||||
await self.hooks.trigger(HookType.BEFORE, context)
|
|
||||||
|
|
||||||
for index, action in enumerate(self.actions):
|
|
||||||
if action._skip_in_chain:
|
|
||||||
logger.debug(
|
|
||||||
"[%s] Skipping consumed action '%s'", self.name, action.name
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
shared_context.current_index = index
|
|
||||||
prepared = action.prepare(shared_context, self.options_manager)
|
|
||||||
try:
|
|
||||||
result = await prepared(*args, **updated_kwargs)
|
|
||||||
except Exception as error:
|
|
||||||
if index + 1 < len(self.actions) and isinstance(
|
|
||||||
self.actions[index + 1], FallbackAction
|
|
||||||
):
|
|
||||||
logger.warning(
|
|
||||||
"[%s] Fallback triggered: %s, recovering with fallback "
|
|
||||||
"'%s'.",
|
|
||||||
self.name,
|
|
||||||
error,
|
|
||||||
self.actions[index + 1].name,
|
|
||||||
)
|
|
||||||
shared_context.add_result(None)
|
|
||||||
context.extra["results"].append(None)
|
|
||||||
fallback = self.actions[index + 1].prepare(shared_context)
|
|
||||||
result = await fallback()
|
|
||||||
fallback._skip_in_chain = True
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
args, updated_kwargs = self._clear_args()
|
|
||||||
shared_context.add_result(result)
|
|
||||||
context.extra["results"].append(result)
|
|
||||||
context.extra["rollback_stack"].append(prepared)
|
|
||||||
|
|
||||||
all_results = context.extra["results"]
|
|
||||||
assert (
|
|
||||||
all_results
|
|
||||||
), f"[{self.name}] No results captured. Something seriously went wrong."
|
|
||||||
context.result = all_results if self.return_list else all_results[-1]
|
|
||||||
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
|
||||||
return context.result
|
|
||||||
|
|
||||||
except Exception as error:
|
|
||||||
context.exception = error
|
|
||||||
shared_context.add_error(shared_context.current_index, error)
|
|
||||||
await self._rollback(context.extra["rollback_stack"], *args, **kwargs)
|
|
||||||
await self.hooks.trigger(HookType.ON_ERROR, context)
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
context.stop_timer()
|
|
||||||
await self.hooks.trigger(HookType.AFTER, context)
|
|
||||||
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
|
||||||
er.record(context)
|
|
||||||
|
|
||||||
async def _rollback(self, rollback_stack, *args, **kwargs):
|
|
||||||
"""
|
|
||||||
Roll back all executed actions in reverse order.
|
|
||||||
|
|
||||||
Rollbacks run even if a fallback recovered from failure,
|
|
||||||
ensuring consistent undo of all side effects.
|
|
||||||
|
|
||||||
Actions without rollback handlers are skipped.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
rollback_stack (list): Actions to roll back.
|
|
||||||
*args, **kwargs: Passed to rollback handlers.
|
|
||||||
"""
|
|
||||||
for action in reversed(rollback_stack):
|
|
||||||
rollback = getattr(action, "rollback", None)
|
|
||||||
if rollback:
|
|
||||||
try:
|
|
||||||
logger.warning("[%s] Rolling back...", action.name)
|
|
||||||
await action.rollback(*args, **kwargs)
|
|
||||||
except Exception as error:
|
|
||||||
logger.error("[%s] Rollback failed: %s", action.name, error)
|
|
||||||
|
|
||||||
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
|
|
||||||
"""Register a hook for all actions and sub-actions."""
|
|
||||||
self.hooks.register(hook_type, hook)
|
|
||||||
for action in self.actions:
|
|
||||||
action.register_hooks_recursively(hook_type, hook)
|
|
||||||
|
|
||||||
async def preview(self, parent: Tree | None = None):
|
|
||||||
label = [f"[{OneColors.CYAN_b}]⛓ ChainedAction[/] '{self.name}'"]
|
|
||||||
if self.inject_last_result:
|
|
||||||
label.append(f" [dim](injects '{self.inject_into}')[/dim]")
|
|
||||||
tree = parent.add("".join(label)) if parent else Tree("".join(label))
|
|
||||||
for action in self.actions:
|
|
||||||
await action.preview(parent=tree)
|
|
||||||
if not parent:
|
|
||||||
self.console.print(tree)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return (
|
|
||||||
f"ChainedAction(name={self.name!r}, "
|
|
||||||
f"actions={[a.name for a in self.actions]!r}, "
|
|
||||||
f"auto_inject={self.auto_inject}, return_list={self.return_list})"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ActionGroup(BaseAction, ActionListMixin):
|
|
||||||
"""
|
|
||||||
ActionGroup executes multiple actions concurrently in parallel.
|
|
||||||
|
|
||||||
It is ideal for independent tasks that can be safely run simultaneously,
|
|
||||||
improving overall throughput and responsiveness of workflows.
|
|
||||||
|
|
||||||
Core features:
|
|
||||||
- Parallel execution of all contained actions.
|
|
||||||
- Shared last_result injection across all actions if configured.
|
|
||||||
- Aggregated collection of individual results as (name, result) pairs.
|
|
||||||
- Hook lifecycle support (before, on_success, on_error, after, on_teardown).
|
|
||||||
- Error aggregation: captures all action errors and reports them together.
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
- If any action fails, the group collects the errors but continues executing
|
|
||||||
other actions without interruption.
|
|
||||||
- After all actions complete, ActionGroup raises a single exception summarizing
|
|
||||||
all failures, or returns all results if successful.
|
|
||||||
|
|
||||||
Best used for:
|
|
||||||
- Batch processing multiple independent tasks.
|
|
||||||
- Reducing latency for workflows with parallelizable steps.
|
|
||||||
- Isolating errors while maximizing successful execution.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name (str): Name of the chain.
|
|
||||||
actions (list): List of actions or literals to execute.
|
|
||||||
hooks (HookManager, optional): Hooks for lifecycle events.
|
|
||||||
inject_last_result (bool, optional): Whether to inject last results into kwargs
|
|
||||||
by default.
|
|
||||||
inject_into (str, optional): Key name for injection.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
actions: list[BaseAction] | None = None,
|
|
||||||
*,
|
|
||||||
hooks: HookManager | None = None,
|
|
||||||
inject_last_result: bool = False,
|
|
||||||
inject_into: str = "last_result",
|
|
||||||
):
|
|
||||||
super().__init__(
|
|
||||||
name,
|
|
||||||
hooks=hooks,
|
|
||||||
inject_last_result=inject_last_result,
|
|
||||||
inject_into=inject_into,
|
|
||||||
)
|
|
||||||
ActionListMixin.__init__(self)
|
|
||||||
if actions:
|
|
||||||
self.set_actions(actions)
|
|
||||||
|
|
||||||
def _wrap_if_needed(self, action: BaseAction | Any) -> BaseAction:
|
|
||||||
if isinstance(action, BaseAction):
|
|
||||||
return action
|
|
||||||
elif callable(action):
|
|
||||||
return Action(name=action.__name__, action=action)
|
|
||||||
else:
|
|
||||||
raise TypeError(
|
|
||||||
"ActionGroup only accepts BaseAction or callable, got "
|
|
||||||
f"{type(action).__name__}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def add_action(self, action: BaseAction | Any) -> None:
|
|
||||||
action = self._wrap_if_needed(action)
|
|
||||||
super().add_action(action)
|
|
||||||
if hasattr(action, "register_teardown") and callable(action.register_teardown):
|
|
||||||
action.register_teardown(self.hooks)
|
|
||||||
|
|
||||||
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
|
|
||||||
arg_defs = same_argument_definitions(self.actions)
|
|
||||||
if arg_defs:
|
|
||||||
return self.actions[0].get_infer_target()
|
|
||||||
logger.debug(
|
|
||||||
"[%s] auto_args disabled: mismatched ActionGroup arguments",
|
|
||||||
self.name,
|
|
||||||
)
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]:
|
|
||||||
shared_context = SharedContext(name=self.name, action=self, is_parallel=True)
|
|
||||||
if self.shared_context:
|
|
||||||
shared_context.set_shared_result(self.shared_context.last_result())
|
|
||||||
updated_kwargs = self._maybe_inject_last_result(kwargs)
|
|
||||||
context = ExecutionContext(
|
|
||||||
name=self.name,
|
|
||||||
args=args,
|
|
||||||
kwargs=updated_kwargs,
|
|
||||||
action=self,
|
|
||||||
extra={"results": [], "errors": []},
|
|
||||||
shared_context=shared_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def run_one(action: BaseAction):
|
|
||||||
try:
|
|
||||||
prepared = action.prepare(shared_context, self.options_manager)
|
|
||||||
result = await prepared(*args, **updated_kwargs)
|
|
||||||
shared_context.add_result((action.name, result))
|
|
||||||
context.extra["results"].append((action.name, result))
|
|
||||||
except Exception as error:
|
|
||||||
shared_context.add_error(shared_context.current_index, error)
|
|
||||||
context.extra["errors"].append((action.name, error))
|
|
||||||
|
|
||||||
context.start_timer()
|
|
||||||
try:
|
|
||||||
await self.hooks.trigger(HookType.BEFORE, context)
|
|
||||||
await asyncio.gather(*[run_one(a) for a in self.actions])
|
|
||||||
|
|
||||||
if context.extra["errors"]:
|
|
||||||
context.exception = Exception(
|
|
||||||
f"{len(context.extra['errors'])} action(s) failed: "
|
|
||||||
f"{' ,'.join(name for name, _ in context.extra['errors'])}"
|
|
||||||
)
|
|
||||||
await self.hooks.trigger(HookType.ON_ERROR, context)
|
|
||||||
raise context.exception
|
|
||||||
|
|
||||||
context.result = context.extra["results"]
|
|
||||||
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
|
||||||
return context.result
|
|
||||||
|
|
||||||
except Exception as error:
|
|
||||||
context.exception = error
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
context.stop_timer()
|
|
||||||
await self.hooks.trigger(HookType.AFTER, context)
|
|
||||||
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
|
||||||
er.record(context)
|
|
||||||
|
|
||||||
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
|
|
||||||
"""Register a hook for all actions and sub-actions."""
|
|
||||||
super().register_hooks_recursively(hook_type, hook)
|
|
||||||
for action in self.actions:
|
|
||||||
action.register_hooks_recursively(hook_type, hook)
|
|
||||||
|
|
||||||
async def preview(self, parent: Tree | None = None):
|
|
||||||
label = [f"[{OneColors.MAGENTA_b}]⏩ ActionGroup (parallel)[/] '{self.name}'"]
|
|
||||||
if self.inject_last_result:
|
|
||||||
label.append(f" [dim](receives '{self.inject_into}')[/dim]")
|
|
||||||
tree = parent.add("".join(label)) if parent else Tree("".join(label))
|
|
||||||
actions = self.actions.copy()
|
|
||||||
random.shuffle(actions)
|
|
||||||
await asyncio.gather(*(action.preview(parent=tree) for action in actions))
|
|
||||||
if not parent:
|
|
||||||
self.console.print(tree)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return (
|
|
||||||
f"ActionGroup(name={self.name!r}, actions={[a.name for a in self.actions]!r},"
|
|
||||||
f" inject_last_result={self.inject_last_result})"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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})"
|
|
||||||
)
|
|
||||||
|
|
|
@ -4,7 +4,7 @@ from typing import Any, Callable
|
||||||
|
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
from falyx.action.action import BaseAction
|
from falyx.action.base import BaseAction
|
||||||
from falyx.context import ExecutionContext
|
from falyx.context import ExecutionContext
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
from falyx.hook_manager import HookType
|
from falyx.hook_manager import HookType
|
||||||
|
|
|
@ -0,0 +1,169 @@
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from rich.tree import Tree
|
||||||
|
|
||||||
|
from falyx.action.action import Action
|
||||||
|
from falyx.action.base import BaseAction
|
||||||
|
from falyx.action.mixins import ActionListMixin
|
||||||
|
from falyx.context import ExecutionContext, SharedContext
|
||||||
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
|
from falyx.hook_manager import Hook, HookManager, HookType
|
||||||
|
from falyx.logger import logger
|
||||||
|
from falyx.parsers.utils import same_argument_definitions
|
||||||
|
from falyx.themes.colors import OneColors
|
||||||
|
|
||||||
|
|
||||||
|
class ActionGroup(BaseAction, ActionListMixin):
|
||||||
|
"""
|
||||||
|
ActionGroup executes multiple actions concurrently in parallel.
|
||||||
|
|
||||||
|
It is ideal for independent tasks that can be safely run simultaneously,
|
||||||
|
improving overall throughput and responsiveness of workflows.
|
||||||
|
|
||||||
|
Core features:
|
||||||
|
- Parallel execution of all contained actions.
|
||||||
|
- Shared last_result injection across all actions if configured.
|
||||||
|
- Aggregated collection of individual results as (name, result) pairs.
|
||||||
|
- Hook lifecycle support (before, on_success, on_error, after, on_teardown).
|
||||||
|
- Error aggregation: captures all action errors and reports them together.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- If any action fails, the group collects the errors but continues executing
|
||||||
|
other actions without interruption.
|
||||||
|
- After all actions complete, ActionGroup raises a single exception summarizing
|
||||||
|
all failures, or returns all results if successful.
|
||||||
|
|
||||||
|
Best used for:
|
||||||
|
- Batch processing multiple independent tasks.
|
||||||
|
- Reducing latency for workflows with parallelizable steps.
|
||||||
|
- Isolating errors while maximizing successful execution.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name (str): Name of the chain.
|
||||||
|
actions (list): List of actions or literals to execute.
|
||||||
|
hooks (HookManager, optional): Hooks for lifecycle events.
|
||||||
|
inject_last_result (bool, optional): Whether to inject last results into kwargs
|
||||||
|
by default.
|
||||||
|
inject_into (str, optional): Key name for injection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
actions: list[BaseAction] | None = None,
|
||||||
|
*,
|
||||||
|
hooks: HookManager | None = None,
|
||||||
|
inject_last_result: bool = False,
|
||||||
|
inject_into: str = "last_result",
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
name,
|
||||||
|
hooks=hooks,
|
||||||
|
inject_last_result=inject_last_result,
|
||||||
|
inject_into=inject_into,
|
||||||
|
)
|
||||||
|
ActionListMixin.__init__(self)
|
||||||
|
if actions:
|
||||||
|
self.set_actions(actions)
|
||||||
|
|
||||||
|
def _wrap_if_needed(self, action: BaseAction | Any) -> BaseAction:
|
||||||
|
if isinstance(action, BaseAction):
|
||||||
|
return action
|
||||||
|
elif callable(action):
|
||||||
|
return Action(name=action.__name__, action=action)
|
||||||
|
else:
|
||||||
|
raise TypeError(
|
||||||
|
"ActionGroup only accepts BaseAction or callable, got "
|
||||||
|
f"{type(action).__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_action(self, action: BaseAction | Any) -> None:
|
||||||
|
action = self._wrap_if_needed(action)
|
||||||
|
super().add_action(action)
|
||||||
|
if hasattr(action, "register_teardown") and callable(action.register_teardown):
|
||||||
|
action.register_teardown(self.hooks)
|
||||||
|
|
||||||
|
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
|
||||||
|
arg_defs = same_argument_definitions(self.actions)
|
||||||
|
if arg_defs:
|
||||||
|
return self.actions[0].get_infer_target()
|
||||||
|
logger.debug(
|
||||||
|
"[%s] auto_args disabled: mismatched ActionGroup arguments",
|
||||||
|
self.name,
|
||||||
|
)
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]:
|
||||||
|
shared_context = SharedContext(name=self.name, action=self, is_parallel=True)
|
||||||
|
if self.shared_context:
|
||||||
|
shared_context.set_shared_result(self.shared_context.last_result())
|
||||||
|
updated_kwargs = self._maybe_inject_last_result(kwargs)
|
||||||
|
context = ExecutionContext(
|
||||||
|
name=self.name,
|
||||||
|
args=args,
|
||||||
|
kwargs=updated_kwargs,
|
||||||
|
action=self,
|
||||||
|
extra={"results": [], "errors": []},
|
||||||
|
shared_context=shared_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run_one(action: BaseAction):
|
||||||
|
try:
|
||||||
|
prepared = action.prepare(shared_context, self.options_manager)
|
||||||
|
result = await prepared(*args, **updated_kwargs)
|
||||||
|
shared_context.add_result((action.name, result))
|
||||||
|
context.extra["results"].append((action.name, result))
|
||||||
|
except Exception as error:
|
||||||
|
shared_context.add_error(shared_context.current_index, error)
|
||||||
|
context.extra["errors"].append((action.name, error))
|
||||||
|
|
||||||
|
context.start_timer()
|
||||||
|
try:
|
||||||
|
await self.hooks.trigger(HookType.BEFORE, context)
|
||||||
|
await asyncio.gather(*[run_one(a) for a in self.actions])
|
||||||
|
|
||||||
|
if context.extra["errors"]:
|
||||||
|
context.exception = Exception(
|
||||||
|
f"{len(context.extra['errors'])} action(s) failed: "
|
||||||
|
f"{' ,'.join(name for name, _ in context.extra['errors'])}"
|
||||||
|
)
|
||||||
|
await self.hooks.trigger(HookType.ON_ERROR, context)
|
||||||
|
raise context.exception
|
||||||
|
|
||||||
|
context.result = context.extra["results"]
|
||||||
|
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||||
|
return context.result
|
||||||
|
|
||||||
|
except Exception as error:
|
||||||
|
context.exception = error
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
context.stop_timer()
|
||||||
|
await self.hooks.trigger(HookType.AFTER, context)
|
||||||
|
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
||||||
|
er.record(context)
|
||||||
|
|
||||||
|
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
|
||||||
|
"""Register a hook for all actions and sub-actions."""
|
||||||
|
super().register_hooks_recursively(hook_type, hook)
|
||||||
|
for action in self.actions:
|
||||||
|
action.register_hooks_recursively(hook_type, hook)
|
||||||
|
|
||||||
|
async def preview(self, parent: Tree | None = None):
|
||||||
|
label = [f"[{OneColors.MAGENTA_b}]⏩ ActionGroup (parallel)[/] '{self.name}'"]
|
||||||
|
if self.inject_last_result:
|
||||||
|
label.append(f" [dim](receives '{self.inject_into}')[/dim]")
|
||||||
|
tree = parent.add("".join(label)) if parent else Tree("".join(label))
|
||||||
|
actions = self.actions.copy()
|
||||||
|
random.shuffle(actions)
|
||||||
|
await asyncio.gather(*(action.preview(parent=tree) for action in actions))
|
||||||
|
if not parent:
|
||||||
|
self.console.print(tree)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return (
|
||||||
|
f"ActionGroup(name={self.name!r}, actions={[a.name for a in self.actions]!r},"
|
||||||
|
f" inject_last_result={self.inject_last_result})"
|
||||||
|
)
|
|
@ -0,0 +1,156 @@
|
||||||
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
|
"""base.py
|
||||||
|
|
||||||
|
Core action system for Falyx.
|
||||||
|
|
||||||
|
This module defines the building blocks for executable actions and workflows,
|
||||||
|
providing a structured way to compose, execute, recover, and manage sequences of
|
||||||
|
operations.
|
||||||
|
|
||||||
|
All actions are callable and follow a unified signature:
|
||||||
|
result = action(*args, **kwargs)
|
||||||
|
|
||||||
|
Core guarantees:
|
||||||
|
- Full hook lifecycle support (before, on_success, on_error, after, on_teardown).
|
||||||
|
- Consistent timing and execution context tracking for each run.
|
||||||
|
- Unified, predictable result handling and error propagation.
|
||||||
|
- Optional last_result injection to enable flexible, data-driven workflows.
|
||||||
|
- Built-in support for retries, rollbacks, parallel groups, chaining, and fallback
|
||||||
|
recovery.
|
||||||
|
|
||||||
|
Key components:
|
||||||
|
- Action: wraps a function or coroutine into a standard executable unit.
|
||||||
|
- ChainedAction: runs actions sequentially, optionally injecting last results.
|
||||||
|
- ActionGroup: runs actions in parallel and gathers results.
|
||||||
|
- ProcessAction: executes CPU-bound functions in a separate process.
|
||||||
|
- LiteralInputAction: injects static values into workflows.
|
||||||
|
- FallbackAction: gracefully recovers from failures or missing data.
|
||||||
|
|
||||||
|
This design promotes clean, fault-tolerant, modular CLI and automation systems.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.tree import Tree
|
||||||
|
|
||||||
|
from falyx.context import SharedContext
|
||||||
|
from falyx.debug import register_debug_hooks
|
||||||
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
|
from falyx.hook_manager import Hook, HookManager, HookType
|
||||||
|
from falyx.logger import logger
|
||||||
|
from falyx.options_manager import OptionsManager
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAction(ABC):
|
||||||
|
"""
|
||||||
|
Base class for actions. Actions can be simple functions or more
|
||||||
|
complex actions like `ChainedAction` or `ActionGroup`. They can also
|
||||||
|
be run independently or as part of Falyx.
|
||||||
|
|
||||||
|
inject_last_result (bool): Whether to inject the previous action's result
|
||||||
|
into kwargs.
|
||||||
|
inject_into (str): The name of the kwarg key to inject the result as
|
||||||
|
(default: 'last_result').
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
hooks: HookManager | None = None,
|
||||||
|
inject_last_result: bool = False,
|
||||||
|
inject_into: str = "last_result",
|
||||||
|
never_prompt: bool = False,
|
||||||
|
logging_hooks: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self.name = name
|
||||||
|
self.hooks = hooks or HookManager()
|
||||||
|
self.is_retryable: bool = False
|
||||||
|
self.shared_context: SharedContext | None = None
|
||||||
|
self.inject_last_result: bool = inject_last_result
|
||||||
|
self.inject_into: str = inject_into
|
||||||
|
self._never_prompt: bool = never_prompt
|
||||||
|
self._skip_in_chain: bool = False
|
||||||
|
self.console = Console(color_system="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")
|
||||||
|
|
||||||
|
@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)
|
|
@ -0,0 +1,208 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from rich.tree import Tree
|
||||||
|
|
||||||
|
from falyx.action.action import Action
|
||||||
|
from falyx.action.base import BaseAction
|
||||||
|
from falyx.action.fallback_action import FallbackAction
|
||||||
|
from falyx.action.literal_input_action import LiteralInputAction
|
||||||
|
from falyx.action.mixins import ActionListMixin
|
||||||
|
from falyx.context import ExecutionContext, SharedContext
|
||||||
|
from falyx.exceptions import EmptyChainError
|
||||||
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
|
from falyx.hook_manager import Hook, HookManager, HookType
|
||||||
|
from falyx.logger import logger
|
||||||
|
from falyx.themes import OneColors
|
||||||
|
|
||||||
|
|
||||||
|
class ChainedAction(BaseAction, ActionListMixin):
|
||||||
|
"""
|
||||||
|
ChainedAction executes a sequence of actions one after another.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Supports optional automatic last_result injection (auto_inject).
|
||||||
|
- Recovers from intermediate errors using FallbackAction if present.
|
||||||
|
- Rolls back all previously executed actions if a failure occurs.
|
||||||
|
- Handles literal values with LiteralInputAction.
|
||||||
|
|
||||||
|
Best used for defining robust, ordered workflows where each step can depend on
|
||||||
|
previous results.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name (str): Name of the chain.
|
||||||
|
actions (list): List of actions or literals to execute.
|
||||||
|
hooks (HookManager, optional): Hooks for lifecycle events.
|
||||||
|
inject_last_result (bool, optional): Whether to inject last results into kwargs
|
||||||
|
by default.
|
||||||
|
inject_into (str, optional): Key name for injection.
|
||||||
|
auto_inject (bool, optional): Auto-enable injection for subsequent actions.
|
||||||
|
return_list (bool, optional): Whether to return a list of all results. False
|
||||||
|
returns the last result.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
actions: list[BaseAction | Any] | None = None,
|
||||||
|
*,
|
||||||
|
hooks: HookManager | None = None,
|
||||||
|
inject_last_result: bool = False,
|
||||||
|
inject_into: str = "last_result",
|
||||||
|
auto_inject: bool = False,
|
||||||
|
return_list: bool = False,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(
|
||||||
|
name,
|
||||||
|
hooks=hooks,
|
||||||
|
inject_last_result=inject_last_result,
|
||||||
|
inject_into=inject_into,
|
||||||
|
)
|
||||||
|
ActionListMixin.__init__(self)
|
||||||
|
self.auto_inject = auto_inject
|
||||||
|
self.return_list = return_list
|
||||||
|
if actions:
|
||||||
|
self.set_actions(actions)
|
||||||
|
|
||||||
|
def _wrap_if_needed(self, action: BaseAction | Any) -> BaseAction:
|
||||||
|
if isinstance(action, BaseAction):
|
||||||
|
return action
|
||||||
|
elif callable(action):
|
||||||
|
return Action(name=action.__name__, action=action)
|
||||||
|
else:
|
||||||
|
return LiteralInputAction(action)
|
||||||
|
|
||||||
|
def add_action(self, action: BaseAction | Any) -> None:
|
||||||
|
action = self._wrap_if_needed(action)
|
||||||
|
if self.actions and self.auto_inject and not action.inject_last_result:
|
||||||
|
action.inject_last_result = True
|
||||||
|
super().add_action(action)
|
||||||
|
if hasattr(action, "register_teardown") and callable(action.register_teardown):
|
||||||
|
action.register_teardown(self.hooks)
|
||||||
|
|
||||||
|
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
|
||||||
|
if self.actions:
|
||||||
|
return self.actions[0].get_infer_target()
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def _clear_args(self):
|
||||||
|
return (), {}
|
||||||
|
|
||||||
|
async def _run(self, *args, **kwargs) -> list[Any]:
|
||||||
|
if not self.actions:
|
||||||
|
raise EmptyChainError(f"[{self.name}] No actions to execute.")
|
||||||
|
|
||||||
|
shared_context = SharedContext(name=self.name, action=self)
|
||||||
|
if self.shared_context:
|
||||||
|
shared_context.add_result(self.shared_context.last_result())
|
||||||
|
updated_kwargs = self._maybe_inject_last_result(kwargs)
|
||||||
|
context = ExecutionContext(
|
||||||
|
name=self.name,
|
||||||
|
args=args,
|
||||||
|
kwargs=updated_kwargs,
|
||||||
|
action=self,
|
||||||
|
extra={"results": [], "rollback_stack": []},
|
||||||
|
shared_context=shared_context,
|
||||||
|
)
|
||||||
|
context.start_timer()
|
||||||
|
try:
|
||||||
|
await self.hooks.trigger(HookType.BEFORE, context)
|
||||||
|
|
||||||
|
for index, action in enumerate(self.actions):
|
||||||
|
if action._skip_in_chain:
|
||||||
|
logger.debug(
|
||||||
|
"[%s] Skipping consumed action '%s'", self.name, action.name
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
shared_context.current_index = index
|
||||||
|
prepared = action.prepare(shared_context, self.options_manager)
|
||||||
|
try:
|
||||||
|
result = await prepared(*args, **updated_kwargs)
|
||||||
|
except Exception as error:
|
||||||
|
if index + 1 < len(self.actions) and isinstance(
|
||||||
|
self.actions[index + 1], FallbackAction
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"[%s] Fallback triggered: %s, recovering with fallback "
|
||||||
|
"'%s'.",
|
||||||
|
self.name,
|
||||||
|
error,
|
||||||
|
self.actions[index + 1].name,
|
||||||
|
)
|
||||||
|
shared_context.add_result(None)
|
||||||
|
context.extra["results"].append(None)
|
||||||
|
fallback = self.actions[index + 1].prepare(shared_context)
|
||||||
|
result = await fallback()
|
||||||
|
fallback._skip_in_chain = True
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
args, updated_kwargs = self._clear_args()
|
||||||
|
shared_context.add_result(result)
|
||||||
|
context.extra["results"].append(result)
|
||||||
|
context.extra["rollback_stack"].append(prepared)
|
||||||
|
|
||||||
|
all_results = context.extra["results"]
|
||||||
|
assert (
|
||||||
|
all_results
|
||||||
|
), f"[{self.name}] No results captured. Something seriously went wrong."
|
||||||
|
context.result = all_results if self.return_list else all_results[-1]
|
||||||
|
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||||
|
return context.result
|
||||||
|
|
||||||
|
except Exception as error:
|
||||||
|
context.exception = error
|
||||||
|
shared_context.add_error(shared_context.current_index, error)
|
||||||
|
await self._rollback(context.extra["rollback_stack"], *args, **kwargs)
|
||||||
|
await self.hooks.trigger(HookType.ON_ERROR, context)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
context.stop_timer()
|
||||||
|
await self.hooks.trigger(HookType.AFTER, context)
|
||||||
|
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
||||||
|
er.record(context)
|
||||||
|
|
||||||
|
async def _rollback(self, rollback_stack, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Roll back all executed actions in reverse order.
|
||||||
|
|
||||||
|
Rollbacks run even if a fallback recovered from failure,
|
||||||
|
ensuring consistent undo of all side effects.
|
||||||
|
|
||||||
|
Actions without rollback handlers are skipped.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rollback_stack (list): Actions to roll back.
|
||||||
|
*args, **kwargs: Passed to rollback handlers.
|
||||||
|
"""
|
||||||
|
for action in reversed(rollback_stack):
|
||||||
|
rollback = getattr(action, "rollback", None)
|
||||||
|
if rollback:
|
||||||
|
try:
|
||||||
|
logger.warning("[%s] Rolling back...", action.name)
|
||||||
|
await action.rollback(*args, **kwargs)
|
||||||
|
except Exception as error:
|
||||||
|
logger.error("[%s] Rollback failed: %s", action.name, error)
|
||||||
|
|
||||||
|
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
|
||||||
|
"""Register a hook for all actions and sub-actions."""
|
||||||
|
self.hooks.register(hook_type, hook)
|
||||||
|
for action in self.actions:
|
||||||
|
action.register_hooks_recursively(hook_type, hook)
|
||||||
|
|
||||||
|
async def preview(self, parent: Tree | None = None):
|
||||||
|
label = [f"[{OneColors.CYAN_b}]⛓ ChainedAction[/] '{self.name}'"]
|
||||||
|
if self.inject_last_result:
|
||||||
|
label.append(f" [dim](injects '{self.inject_into}')[/dim]")
|
||||||
|
tree = parent.add("".join(label)) if parent else Tree("".join(label))
|
||||||
|
for action in self.actions:
|
||||||
|
await action.preview(parent=tree)
|
||||||
|
if not parent:
|
||||||
|
self.console.print(tree)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return (
|
||||||
|
f"ChainedAction(name={self.name!r}, "
|
||||||
|
f"actions={[a.name for a in self.actions]!r}, "
|
||||||
|
f"auto_inject={self.auto_inject}, return_list={self.return_list})"
|
||||||
|
)
|
|
@ -0,0 +1,49 @@
|
||||||
|
from functools import cached_property
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from rich.tree import Tree
|
||||||
|
|
||||||
|
from falyx.action.action import Action
|
||||||
|
from falyx.themes import OneColors
|
||||||
|
|
||||||
|
|
||||||
|
class FallbackAction(Action):
|
||||||
|
"""
|
||||||
|
FallbackAction provides a default value if the previous action failed or
|
||||||
|
returned None.
|
||||||
|
|
||||||
|
It injects the last result and checks:
|
||||||
|
- If last_result is not None, it passes it through unchanged.
|
||||||
|
- If last_result is None (e.g., due to failure), it replaces it with a fallback value.
|
||||||
|
|
||||||
|
Used in ChainedAction pipelines to gracefully recover from errors or missing data.
|
||||||
|
When activated, it consumes the preceding error and allows the chain to continue
|
||||||
|
normally.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fallback (Any): The fallback value to use if last_result is None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, fallback: Any):
|
||||||
|
self._fallback = fallback
|
||||||
|
|
||||||
|
async def _fallback_logic(last_result):
|
||||||
|
return last_result if last_result is not None else fallback
|
||||||
|
|
||||||
|
super().__init__(name="Fallback", action=_fallback_logic, inject_last_result=True)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def fallback(self) -> Any:
|
||||||
|
"""Return the fallback value."""
|
||||||
|
return self._fallback
|
||||||
|
|
||||||
|
async def preview(self, parent: Tree | None = None):
|
||||||
|
label = [f"[{OneColors.LIGHT_RED}]🛟 Fallback[/] '{self.name}'"]
|
||||||
|
label.append(f" [dim](uses fallback = {repr(self.fallback)})[/dim]")
|
||||||
|
if parent:
|
||||||
|
parent.add("".join(label))
|
||||||
|
else:
|
||||||
|
self.console.print(Tree("".join(label)))
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"FallbackAction(fallback={self.fallback!r})"
|
|
@ -23,7 +23,7 @@ from typing import Any, Callable
|
||||||
|
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
from falyx.action.action import BaseAction
|
from falyx.action.base import BaseAction
|
||||||
from falyx.context import ExecutionContext
|
from falyx.context import ExecutionContext
|
||||||
from falyx.exceptions import FalyxError
|
from falyx.exceptions import FalyxError
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import cached_property
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from rich.tree import Tree
|
||||||
|
|
||||||
|
from falyx.action.action import Action
|
||||||
|
from falyx.themes import OneColors
|
||||||
|
|
||||||
|
|
||||||
|
class LiteralInputAction(Action):
|
||||||
|
"""
|
||||||
|
LiteralInputAction injects a static value into a ChainedAction.
|
||||||
|
|
||||||
|
This allows embedding hardcoded values mid-pipeline, useful when:
|
||||||
|
- Providing default or fallback inputs.
|
||||||
|
- Starting a pipeline with a fixed input.
|
||||||
|
- Supplying missing context manually.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value (Any): The static value to inject.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, value: Any):
|
||||||
|
self._value = value
|
||||||
|
|
||||||
|
async def literal(*_, **__):
|
||||||
|
return value
|
||||||
|
|
||||||
|
super().__init__("Input", literal)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def value(self) -> Any:
|
||||||
|
"""Return the literal value."""
|
||||||
|
return self._value
|
||||||
|
|
||||||
|
async def preview(self, parent: Tree | None = None):
|
||||||
|
label = [f"[{OneColors.LIGHT_YELLOW}]📥 LiteralInput[/] '{self.name}'"]
|
||||||
|
label.append(f" [dim](value = {repr(self.value)})[/dim]")
|
||||||
|
if parent:
|
||||||
|
parent.add("".join(label))
|
||||||
|
else:
|
||||||
|
self.console.print(Tree("".join(label)))
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"LiteralInputAction(value={self.value!r})"
|
|
@ -7,7 +7,7 @@ from rich.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
from falyx.action.action import BaseAction
|
from falyx.action.base import BaseAction
|
||||||
from falyx.context import ExecutionContext
|
from falyx.context import ExecutionContext
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
from falyx.hook_manager import HookType
|
from falyx.hook_manager import HookType
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
from falyx.action.base import BaseAction
|
||||||
|
|
||||||
|
|
||||||
|
class ActionListMixin:
|
||||||
|
"""Mixin for managing a list of actions."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.actions: list[BaseAction] = []
|
||||||
|
|
||||||
|
def set_actions(self, actions: list[BaseAction]) -> None:
|
||||||
|
"""Replaces the current action list with a new one."""
|
||||||
|
self.actions.clear()
|
||||||
|
for action in actions:
|
||||||
|
self.add_action(action)
|
||||||
|
|
||||||
|
def add_action(self, action: BaseAction) -> None:
|
||||||
|
"""Adds an action to the list."""
|
||||||
|
self.actions.append(action)
|
||||||
|
|
||||||
|
def remove_action(self, name: str) -> None:
|
||||||
|
"""Removes an action by name."""
|
||||||
|
self.actions = [action for action in self.actions if action.name != name]
|
||||||
|
|
||||||
|
def has_action(self, name: str) -> bool:
|
||||||
|
"""Checks if an action with the given name exists."""
|
||||||
|
return any(action.name == name for action in self.actions)
|
||||||
|
|
||||||
|
def get_action(self, name: str) -> BaseAction | None:
|
||||||
|
"""Retrieves an action by name."""
|
||||||
|
for action in self.actions:
|
||||||
|
if action.name == name:
|
||||||
|
return action
|
||||||
|
return None
|
|
@ -0,0 +1,128 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
|
from functools import partial
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from rich.tree import Tree
|
||||||
|
|
||||||
|
from falyx.action.base import BaseAction
|
||||||
|
from falyx.context import ExecutionContext
|
||||||
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
|
from falyx.hook_manager import HookManager, HookType
|
||||||
|
from falyx.themes import OneColors
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessAction(BaseAction):
|
||||||
|
"""
|
||||||
|
ProcessAction runs a function in a separate process using ProcessPoolExecutor.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Executes CPU-bound or blocking tasks without blocking the main event loop.
|
||||||
|
- Supports last_result injection into the subprocess.
|
||||||
|
- Validates that last_result is pickleable when injection is enabled.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name (str): Name of the action.
|
||||||
|
func (Callable): Function to execute in a new process.
|
||||||
|
args (tuple, optional): Positional arguments.
|
||||||
|
kwargs (dict, optional): Keyword arguments.
|
||||||
|
hooks (HookManager, optional): Hook manager for lifecycle events.
|
||||||
|
executor (ProcessPoolExecutor, optional): Custom executor if desired.
|
||||||
|
inject_last_result (bool, optional): Inject last result into the function.
|
||||||
|
inject_into (str, optional): Name of the injected key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
action: Callable[..., Any],
|
||||||
|
*,
|
||||||
|
args: tuple = (),
|
||||||
|
kwargs: dict[str, Any] | None = None,
|
||||||
|
hooks: HookManager | None = None,
|
||||||
|
executor: ProcessPoolExecutor | None = None,
|
||||||
|
inject_last_result: bool = False,
|
||||||
|
inject_into: str = "last_result",
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
name,
|
||||||
|
hooks=hooks,
|
||||||
|
inject_last_result=inject_last_result,
|
||||||
|
inject_into=inject_into,
|
||||||
|
)
|
||||||
|
self.action = action
|
||||||
|
self.args = args
|
||||||
|
self.kwargs = kwargs or {}
|
||||||
|
self.executor = executor or ProcessPoolExecutor()
|
||||||
|
self.is_retryable = True
|
||||||
|
|
||||||
|
def get_infer_target(self) -> tuple[Callable[..., Any] | None, None]:
|
||||||
|
return self.action, None
|
||||||
|
|
||||||
|
async def _run(self, *args, **kwargs) -> Any:
|
||||||
|
if self.inject_last_result and self.shared_context:
|
||||||
|
last_result = self.shared_context.last_result()
|
||||||
|
if not self._validate_pickleable(last_result):
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot inject last result into {self.name}: "
|
||||||
|
f"last result is not pickleable."
|
||||||
|
)
|
||||||
|
combined_args = args + self.args
|
||||||
|
combined_kwargs = self._maybe_inject_last_result({**self.kwargs, **kwargs})
|
||||||
|
context = ExecutionContext(
|
||||||
|
name=self.name,
|
||||||
|
args=combined_args,
|
||||||
|
kwargs=combined_kwargs,
|
||||||
|
action=self,
|
||||||
|
)
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
context.start_timer()
|
||||||
|
try:
|
||||||
|
await self.hooks.trigger(HookType.BEFORE, context)
|
||||||
|
result = await loop.run_in_executor(
|
||||||
|
self.executor, partial(self.action, *combined_args, **combined_kwargs)
|
||||||
|
)
|
||||||
|
context.result = result
|
||||||
|
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||||
|
return result
|
||||||
|
except Exception as error:
|
||||||
|
context.exception = error
|
||||||
|
await self.hooks.trigger(HookType.ON_ERROR, context)
|
||||||
|
if context.result is not None:
|
||||||
|
return context.result
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
context.stop_timer()
|
||||||
|
await self.hooks.trigger(HookType.AFTER, context)
|
||||||
|
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
||||||
|
er.record(context)
|
||||||
|
|
||||||
|
def _validate_pickleable(self, obj: Any) -> bool:
|
||||||
|
try:
|
||||||
|
import pickle
|
||||||
|
|
||||||
|
pickle.dumps(obj)
|
||||||
|
return True
|
||||||
|
except (pickle.PicklingError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def preview(self, parent: Tree | None = None):
|
||||||
|
label = [
|
||||||
|
f"[{OneColors.DARK_YELLOW_b}]🧠 ProcessAction (new process)[/] '{self.name}'"
|
||||||
|
]
|
||||||
|
if self.inject_last_result:
|
||||||
|
label.append(f" [dim](injects '{self.inject_into}')[/dim]")
|
||||||
|
if parent:
|
||||||
|
parent.add("".join(label))
|
||||||
|
else:
|
||||||
|
self.console.print(Tree("".join(label)))
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"ProcessAction(name={self.name!r}, "
|
||||||
|
f"action={getattr(self.action, '__name__', repr(self.action))}, "
|
||||||
|
f"args={self.args!r}, kwargs={self.kwargs!r})"
|
||||||
|
)
|
|
@ -7,7 +7,7 @@ from prompt_toolkit.formatted_text import FormattedText, merge_formatted_text
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
from falyx.action.action import BaseAction
|
from falyx.action.base import BaseAction
|
||||||
from falyx.context import ExecutionContext
|
from falyx.context import ExecutionContext
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
from falyx.hook_manager import HookType
|
from falyx.hook_manager import HookType
|
||||||
|
|
|
@ -14,7 +14,7 @@ from prompt_toolkit import PromptSession
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
from falyx.action.action import BaseAction
|
from falyx.action.base import BaseAction
|
||||||
from falyx.action.types import FileReturnType
|
from falyx.action.types import FileReturnType
|
||||||
from falyx.context import ExecutionContext
|
from falyx.context import ExecutionContext
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
|
|
|
@ -1,13 +1,12 @@
|
||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
"""selection_action.py"""
|
"""selection_action.py"""
|
||||||
from copy import copy
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from prompt_toolkit import PromptSession
|
from prompt_toolkit import PromptSession
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
from falyx.action.action import BaseAction
|
from falyx.action.base import BaseAction
|
||||||
from falyx.action.types import SelectionReturnType
|
from falyx.action.types import SelectionReturnType
|
||||||
from falyx.context import ExecutionContext
|
from falyx.context import ExecutionContext
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
|
|
|
@ -3,7 +3,7 @@ from prompt_toolkit.validation import Validator
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
from falyx.action import BaseAction
|
from falyx.action.base import BaseAction
|
||||||
from falyx.context import ExecutionContext
|
from falyx.context import ExecutionContext
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
from falyx.hook_manager import HookType
|
from falyx.hook_manager import HookType
|
||||||
|
|
|
@ -26,7 +26,8 @@ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
from falyx.action.action import Action, BaseAction
|
from falyx.action.action import Action
|
||||||
|
from falyx.action.base import BaseAction
|
||||||
from falyx.context import ExecutionContext
|
from falyx.context import ExecutionContext
|
||||||
from falyx.debug import register_debug_hooks
|
from falyx.debug import register_debug_hooks
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
|
|
|
@ -13,7 +13,8 @@ import yaml
|
||||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
from falyx.action.action import Action, BaseAction
|
from falyx.action.action import Action
|
||||||
|
from falyx.action.base import BaseAction
|
||||||
from falyx.command import Command
|
from falyx.command import Command
|
||||||
from falyx.falyx import Falyx
|
from falyx.falyx import Falyx
|
||||||
from falyx.logger import logger
|
from falyx.logger import logger
|
||||||
|
|
|
@ -42,7 +42,8 @@ from rich.console import Console
|
||||||
from rich.markdown import Markdown
|
from rich.markdown import Markdown
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
from falyx.action.action import Action, BaseAction
|
from falyx.action.action import Action
|
||||||
|
from falyx.action.base import BaseAction
|
||||||
from falyx.bottom_bar import BottomBar
|
from falyx.bottom_bar import BottomBar
|
||||||
from falyx.command import Command
|
from falyx.command import Command
|
||||||
from falyx.context import ExecutionContext
|
from falyx.context import ExecutionContext
|
||||||
|
@ -82,7 +83,7 @@ class CommandValidator(Validator):
|
||||||
self.falyx = falyx
|
self.falyx = falyx
|
||||||
self.error_message = error_message
|
self.error_message = error_message
|
||||||
|
|
||||||
def validate(self, document) -> None:
|
def validate(self, _) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def validate_async(self, document) -> None:
|
async def validate_async(self, document) -> None:
|
||||||
|
@ -449,7 +450,7 @@ class Falyx:
|
||||||
validator=CommandValidator(self, self._get_validator_error_message()),
|
validator=CommandValidator(self, self._get_validator_error_message()),
|
||||||
bottom_toolbar=self._get_bottom_bar_render(),
|
bottom_toolbar=self._get_bottom_bar_render(),
|
||||||
key_bindings=self.key_bindings,
|
key_bindings=self.key_bindings,
|
||||||
validate_while_typing=False,
|
validate_while_typing=True,
|
||||||
)
|
)
|
||||||
return self._prompt_session
|
return self._prompt_session
|
||||||
|
|
||||||
|
@ -761,7 +762,7 @@ class Falyx:
|
||||||
is_preview = False
|
is_preview = False
|
||||||
choice = "?"
|
choice = "?"
|
||||||
elif is_preview and not choice:
|
elif is_preview and not choice:
|
||||||
# No help command enabled
|
# No help (list) command enabled
|
||||||
if not from_validate:
|
if not from_validate:
|
||||||
self.console.print(
|
self.console.print(
|
||||||
f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode."
|
f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode."
|
||||||
|
@ -781,12 +782,9 @@ class Falyx:
|
||||||
)
|
)
|
||||||
except CommandArgumentError as error:
|
except CommandArgumentError as error:
|
||||||
if not from_validate:
|
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()
|
name_map[choice].show_help()
|
||||||
|
self.console.print(f"[{OneColors.DARK_RED}]❌ [{choice}]: {error}")
|
||||||
|
else:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
message=str(error), cursor_position=len(raw_choices)
|
message=str(error), cursor_position=len(raw_choices)
|
||||||
)
|
)
|
||||||
|
@ -806,14 +804,24 @@ class Falyx:
|
||||||
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'. "
|
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'. "
|
||||||
"Did you mean:"
|
"Did you mean:"
|
||||||
)
|
)
|
||||||
for match in fuzzy_matches:
|
for match in fuzzy_matches:
|
||||||
cmd = name_map[match]
|
cmd = name_map[match]
|
||||||
self.console.print(f" • [bold]{match}[/] → {cmd.description}")
|
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:
|
else:
|
||||||
if not from_validate:
|
if not from_validate:
|
||||||
self.console.print(
|
self.console.print(
|
||||||
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'[/]"
|
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'[/]"
|
||||||
)
|
)
|
||||||
|
raise ValidationError(
|
||||||
|
message=f"Unknown command '{choice}'.",
|
||||||
|
cursor_position=len(raw_choices),
|
||||||
|
)
|
||||||
return is_preview, None, args, kwargs
|
return is_preview, None, args, kwargs
|
||||||
|
|
||||||
def _create_context(self, selected_command: Command) -> ExecutionContext:
|
def _create_context(self, selected_command: Command) -> ExecutionContext:
|
||||||
|
@ -974,7 +982,7 @@ class Falyx:
|
||||||
|
|
||||||
async def menu(self) -> None:
|
async def menu(self) -> None:
|
||||||
"""Runs the menu and handles user input."""
|
"""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()
|
self.debug_hooks()
|
||||||
if self.welcome_message:
|
if self.welcome_message:
|
||||||
self.print_message(self.welcome_message)
|
self.print_message(self.welcome_message)
|
||||||
|
|
|
@ -4,7 +4,7 @@ from dataclasses import dataclass
|
||||||
|
|
||||||
from prompt_toolkit.formatted_text import FormattedText
|
from prompt_toolkit.formatted_text import FormattedText
|
||||||
|
|
||||||
from falyx.action import BaseAction
|
from falyx.action.base import BaseAction
|
||||||
from falyx.signals import BackSignal, QuitSignal
|
from falyx.signals import BackSignal, QuitSignal
|
||||||
from falyx.themes import OneColors
|
from falyx.themes import OneColors
|
||||||
from falyx.utils import CaseInsensitiveDict
|
from falyx.utils import CaseInsensitiveDict
|
||||||
|
|
|
@ -10,6 +10,7 @@ from rich.console import Console
|
||||||
from rich.markup import escape
|
from rich.markup import escape
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
|
from falyx.action.base import BaseAction
|
||||||
from falyx.exceptions import CommandArgumentError
|
from falyx.exceptions import CommandArgumentError
|
||||||
from falyx.signals import HelpSignal
|
from falyx.signals import HelpSignal
|
||||||
|
|
||||||
|
@ -17,6 +18,7 @@ from falyx.signals import HelpSignal
|
||||||
class ArgumentAction(Enum):
|
class ArgumentAction(Enum):
|
||||||
"""Defines the action to be taken when the argument is encountered."""
|
"""Defines the action to be taken when the argument is encountered."""
|
||||||
|
|
||||||
|
ACTION = "action"
|
||||||
STORE = "store"
|
STORE = "store"
|
||||||
STORE_TRUE = "store_true"
|
STORE_TRUE = "store_true"
|
||||||
STORE_FALSE = "store_false"
|
STORE_FALSE = "store_false"
|
||||||
|
@ -51,6 +53,7 @@ class Argument:
|
||||||
help: str = "" # Help text for the argument
|
help: str = "" # Help text for the argument
|
||||||
nargs: int | str | None = None # int, '?', '*', '+', None
|
nargs: int | str | None = None # int, '?', '*', '+', None
|
||||||
positional: bool = False # True if no leading - or -- in flags
|
positional: bool = False # True if no leading - or -- in flags
|
||||||
|
resolver: BaseAction | None = None # Action object for the argument
|
||||||
|
|
||||||
def get_positional_text(self) -> str:
|
def get_positional_text(self) -> str:
|
||||||
"""Get the positional text for the argument."""
|
"""Get the positional text for the argument."""
|
||||||
|
@ -104,6 +107,8 @@ class Argument:
|
||||||
and self.required == other.required
|
and self.required == other.required
|
||||||
and self.nargs == other.nargs
|
and self.nargs == other.nargs
|
||||||
and self.positional == other.positional
|
and self.positional == other.positional
|
||||||
|
and self.default == other.default
|
||||||
|
and self.help == other.help
|
||||||
)
|
)
|
||||||
|
|
||||||
def __hash__(self) -> int:
|
def __hash__(self) -> int:
|
||||||
|
@ -117,6 +122,8 @@ class Argument:
|
||||||
self.required,
|
self.required,
|
||||||
self.nargs,
|
self.nargs,
|
||||||
self.positional,
|
self.positional,
|
||||||
|
self.default,
|
||||||
|
self.help,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -220,6 +227,12 @@ class CommandArgumentParser:
|
||||||
if required:
|
if required:
|
||||||
return True
|
return True
|
||||||
if positional:
|
if positional:
|
||||||
|
assert (
|
||||||
|
nargs is None
|
||||||
|
or isinstance(nargs, int)
|
||||||
|
or isinstance(nargs, str)
|
||||||
|
and nargs in ("+", "*", "?")
|
||||||
|
), f"Invalid nargs value: {nargs}"
|
||||||
if isinstance(nargs, int):
|
if isinstance(nargs, int):
|
||||||
return nargs > 0
|
return nargs > 0
|
||||||
elif isinstance(nargs, str):
|
elif isinstance(nargs, str):
|
||||||
|
@ -227,8 +240,8 @@ class CommandArgumentParser:
|
||||||
return True
|
return True
|
||||||
elif nargs in ("*", "?"):
|
elif nargs in ("*", "?"):
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
raise CommandArgumentError(f"Invalid nargs value: {nargs}")
|
return True
|
||||||
|
|
||||||
return required
|
return required
|
||||||
|
|
||||||
|
@ -247,7 +260,7 @@ class CommandArgumentParser:
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
if nargs is None:
|
if nargs is None:
|
||||||
nargs = 1
|
return None
|
||||||
allowed_nargs = ("?", "*", "+")
|
allowed_nargs = ("?", "*", "+")
|
||||||
if isinstance(nargs, int):
|
if isinstance(nargs, int):
|
||||||
if nargs <= 0:
|
if nargs <= 0:
|
||||||
|
@ -308,6 +321,23 @@ class CommandArgumentParser:
|
||||||
f"Default list value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__}"
|
f"Default list value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _validate_resolver(
|
||||||
|
self, action: ArgumentAction, resolver: BaseAction | None
|
||||||
|
) -> BaseAction | None:
|
||||||
|
"""Validate the action object."""
|
||||||
|
if action != ArgumentAction.ACTION and resolver is None:
|
||||||
|
return None
|
||||||
|
elif action == ArgumentAction.ACTION and resolver is None:
|
||||||
|
raise CommandArgumentError("resolver must be provided for ACTION action")
|
||||||
|
elif action != ArgumentAction.ACTION and resolver is not None:
|
||||||
|
raise CommandArgumentError(
|
||||||
|
f"resolver should not be provided for action {action}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(resolver, BaseAction):
|
||||||
|
raise CommandArgumentError("resolver must be an instance of BaseAction")
|
||||||
|
return resolver
|
||||||
|
|
||||||
def _validate_action(
|
def _validate_action(
|
||||||
self, action: ArgumentAction | str, positional: bool
|
self, action: ArgumentAction | str, positional: bool
|
||||||
) -> ArgumentAction:
|
) -> ArgumentAction:
|
||||||
|
@ -347,6 +377,8 @@ class CommandArgumentParser:
|
||||||
return 0
|
return 0
|
||||||
elif action in (ArgumentAction.APPEND, ArgumentAction.EXTEND):
|
elif action in (ArgumentAction.APPEND, ArgumentAction.EXTEND):
|
||||||
return []
|
return []
|
||||||
|
elif isinstance(nargs, int):
|
||||||
|
return []
|
||||||
elif nargs in ("+", "*"):
|
elif nargs in ("+", "*"):
|
||||||
return []
|
return []
|
||||||
else:
|
else:
|
||||||
|
@ -380,8 +412,15 @@ class CommandArgumentParser:
|
||||||
required: bool = False,
|
required: bool = False,
|
||||||
help: str = "",
|
help: str = "",
|
||||||
dest: str | None = None,
|
dest: str | None = None,
|
||||||
|
resolver: BaseAction | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add an argument to the parser.
|
"""Add an argument to the parser.
|
||||||
|
For `ArgumentAction.ACTION`, `nargs` and `type` determine how many and what kind
|
||||||
|
of inputs are passed to the `resolver`.
|
||||||
|
|
||||||
|
The return value of the `resolver` is used directly (no type coercion is applied).
|
||||||
|
Validation, structure, and post-processing should be handled within the `resolver`.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name or flags: Either a name or prefixed flags (e.g. 'faylx', '-f', '--falyx').
|
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.
|
action: The action to be taken when the argument is encountered.
|
||||||
|
@ -392,6 +431,7 @@ class CommandArgumentParser:
|
||||||
required: Whether or not the argument is required.
|
required: Whether or not the argument is required.
|
||||||
help: A brief description of the argument.
|
help: A brief description of the argument.
|
||||||
dest: The name of the attribute to be added to the object returned by parse_args().
|
dest: The name of the attribute to be added to the object returned by parse_args().
|
||||||
|
resolver: A BaseAction called with optional nargs specified parsed arguments.
|
||||||
"""
|
"""
|
||||||
expected_type = type
|
expected_type = type
|
||||||
self._validate_flags(flags)
|
self._validate_flags(flags)
|
||||||
|
@ -403,8 +443,8 @@ class CommandArgumentParser:
|
||||||
"Merging multiple arguments into the same dest (e.g. positional + flagged) "
|
"Merging multiple arguments into the same dest (e.g. positional + flagged) "
|
||||||
"is not supported. Define a unique 'dest' for each argument."
|
"is not supported. Define a unique 'dest' for each argument."
|
||||||
)
|
)
|
||||||
self._dest_set.add(dest)
|
|
||||||
action = self._validate_action(action, positional)
|
action = self._validate_action(action, positional)
|
||||||
|
resolver = self._validate_resolver(action, resolver)
|
||||||
nargs = self._validate_nargs(nargs, action)
|
nargs = self._validate_nargs(nargs, action)
|
||||||
default = self._resolve_default(default, action, nargs)
|
default = self._resolve_default(default, action, nargs)
|
||||||
if (
|
if (
|
||||||
|
@ -432,6 +472,7 @@ class CommandArgumentParser:
|
||||||
help=help,
|
help=help,
|
||||||
nargs=nargs,
|
nargs=nargs,
|
||||||
positional=positional,
|
positional=positional,
|
||||||
|
resolver=resolver,
|
||||||
)
|
)
|
||||||
for flag in flags:
|
for flag in flags:
|
||||||
if flag in self._flag_map:
|
if flag in self._flag_map:
|
||||||
|
@ -439,7 +480,9 @@ class CommandArgumentParser:
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
f"Flag '{flag}' is already used by argument '{existing.dest}'"
|
f"Flag '{flag}' is already used by argument '{existing.dest}'"
|
||||||
)
|
)
|
||||||
|
for flag in flags:
|
||||||
self._flag_map[flag] = argument
|
self._flag_map[flag] = argument
|
||||||
|
self._dest_set.add(dest)
|
||||||
self._arguments.append(argument)
|
self._arguments.append(argument)
|
||||||
if positional:
|
if positional:
|
||||||
self._positional.append(argument)
|
self._positional.append(argument)
|
||||||
|
@ -462,6 +505,8 @@ class CommandArgumentParser:
|
||||||
"required": arg.required,
|
"required": arg.required,
|
||||||
"nargs": arg.nargs,
|
"nargs": arg.nargs,
|
||||||
"positional": arg.positional,
|
"positional": arg.positional,
|
||||||
|
"default": arg.default,
|
||||||
|
"help": arg.help,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return defs
|
return defs
|
||||||
|
@ -469,14 +514,17 @@ class CommandArgumentParser:
|
||||||
def _consume_nargs(
|
def _consume_nargs(
|
||||||
self, args: list[str], start: int, spec: Argument
|
self, args: list[str], start: int, spec: Argument
|
||||||
) -> tuple[list[str], int]:
|
) -> tuple[list[str], int]:
|
||||||
|
assert (
|
||||||
|
spec.nargs is None
|
||||||
|
or isinstance(spec.nargs, int)
|
||||||
|
or isinstance(spec.nargs, str)
|
||||||
|
and spec.nargs in ("+", "*", "?")
|
||||||
|
), f"Invalid nargs value: {spec.nargs}"
|
||||||
values = []
|
values = []
|
||||||
i = start
|
i = start
|
||||||
if isinstance(spec.nargs, int):
|
if isinstance(spec.nargs, int):
|
||||||
values = args[i : i + spec.nargs]
|
values = args[i : i + spec.nargs]
|
||||||
return values, i + spec.nargs
|
return values, i + spec.nargs
|
||||||
elif spec.nargs is None:
|
|
||||||
values = [args[i]]
|
|
||||||
return values, i + 1
|
|
||||||
elif spec.nargs == "+":
|
elif spec.nargs == "+":
|
||||||
if i >= len(args):
|
if i >= len(args):
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
|
@ -496,10 +544,13 @@ class CommandArgumentParser:
|
||||||
if i < len(args) and not args[i].startswith("-"):
|
if i < len(args) and not args[i].startswith("-"):
|
||||||
return [args[i]], i + 1
|
return [args[i]], i + 1
|
||||||
return [], i
|
return [], i
|
||||||
else:
|
elif spec.nargs is None:
|
||||||
assert False, "Invalid nargs value: shouldn't happen"
|
if i < len(args) and not args[i].startswith("-"):
|
||||||
|
return [args[i]], i + 1
|
||||||
|
return [], i
|
||||||
|
assert False, "Invalid nargs value: shouldn't happen"
|
||||||
|
|
||||||
def _consume_all_positional_args(
|
async def _consume_all_positional_args(
|
||||||
self,
|
self,
|
||||||
args: list[str],
|
args: list[str],
|
||||||
result: dict[str, Any],
|
result: dict[str, Any],
|
||||||
|
@ -519,18 +570,22 @@ class CommandArgumentParser:
|
||||||
remaining = len(args) - i
|
remaining = len(args) - i
|
||||||
min_required = 0
|
min_required = 0
|
||||||
for next_spec in positional_args[j + 1 :]:
|
for next_spec in positional_args[j + 1 :]:
|
||||||
if isinstance(next_spec.nargs, int):
|
assert (
|
||||||
min_required += next_spec.nargs
|
next_spec.nargs is None
|
||||||
elif next_spec.nargs is None:
|
or isinstance(next_spec.nargs, int)
|
||||||
|
or isinstance(next_spec.nargs, str)
|
||||||
|
and next_spec.nargs in ("+", "*", "?")
|
||||||
|
), f"Invalid nargs value: {spec.nargs}"
|
||||||
|
if next_spec.nargs is None:
|
||||||
min_required += 1
|
min_required += 1
|
||||||
|
elif isinstance(next_spec.nargs, int):
|
||||||
|
min_required += next_spec.nargs
|
||||||
elif next_spec.nargs == "+":
|
elif next_spec.nargs == "+":
|
||||||
min_required += 1
|
min_required += 1
|
||||||
elif next_spec.nargs == "?":
|
elif next_spec.nargs == "?":
|
||||||
min_required += 0
|
min_required += 0
|
||||||
elif next_spec.nargs == "*":
|
elif next_spec.nargs == "*":
|
||||||
min_required += 0
|
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)]
|
slice_args = args[i:] if is_last else args[i : i + (remaining - min_required)]
|
||||||
values, new_i = self._consume_nargs(slice_args, 0, spec)
|
values, new_i = self._consume_nargs(slice_args, 0, spec)
|
||||||
|
@ -542,10 +597,19 @@ class CommandArgumentParser:
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
|
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
|
||||||
)
|
)
|
||||||
|
if spec.action == ArgumentAction.ACTION:
|
||||||
if spec.action == ArgumentAction.APPEND:
|
assert isinstance(
|
||||||
|
spec.resolver, BaseAction
|
||||||
|
), "resolver should be an instance of BaseAction"
|
||||||
|
try:
|
||||||
|
result[spec.dest] = await spec.resolver(*typed)
|
||||||
|
except Exception as error:
|
||||||
|
raise CommandArgumentError(
|
||||||
|
f"[{spec.dest}] Action failed: {error}"
|
||||||
|
) from error
|
||||||
|
elif spec.action == ArgumentAction.APPEND:
|
||||||
assert result.get(spec.dest) is not None, "dest should not be None"
|
assert result.get(spec.dest) is not None, "dest should not be None"
|
||||||
if spec.nargs in (None, 1):
|
if spec.nargs is None:
|
||||||
result[spec.dest].append(typed[0])
|
result[spec.dest].append(typed[0])
|
||||||
else:
|
else:
|
||||||
result[spec.dest].append(typed)
|
result[spec.dest].append(typed)
|
||||||
|
@ -565,6 +629,23 @@ class CommandArgumentParser:
|
||||||
|
|
||||||
return i
|
return i
|
||||||
|
|
||||||
|
def _expand_posix_bundling(self, args: list[str]) -> list[str]:
|
||||||
|
"""Expand POSIX-style bundled arguments into separate arguments."""
|
||||||
|
expanded = []
|
||||||
|
for token in args:
|
||||||
|
if token.startswith("-") and not token.startswith("--") and len(token) > 2:
|
||||||
|
# POSIX bundle
|
||||||
|
# e.g. -abc -> -a -b -c
|
||||||
|
for char in token[1:]:
|
||||||
|
flag = f"-{char}"
|
||||||
|
arg = self._flag_map.get(flag)
|
||||||
|
if not arg:
|
||||||
|
raise CommandArgumentError(f"Unrecognized option: {flag}")
|
||||||
|
expanded.append(flag)
|
||||||
|
else:
|
||||||
|
expanded.append(token)
|
||||||
|
return expanded
|
||||||
|
|
||||||
async def parse_args(
|
async def parse_args(
|
||||||
self, args: list[str] | None = None, from_validate: bool = False
|
self, args: list[str] | None = None, from_validate: bool = False
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
@ -572,11 +653,13 @@ class CommandArgumentParser:
|
||||||
if args is None:
|
if args is None:
|
||||||
args = []
|
args = []
|
||||||
|
|
||||||
|
args = self._expand_posix_bundling(args)
|
||||||
|
|
||||||
result = {arg.dest: deepcopy(arg.default) for arg in self._arguments}
|
result = {arg.dest: deepcopy(arg.default) for arg in self._arguments}
|
||||||
positional_args = [arg for arg in self._arguments if arg.positional]
|
positional_args = [arg for arg in self._arguments if arg.positional]
|
||||||
consumed_positional_indices: set[int] = set()
|
consumed_positional_indices: set[int] = set()
|
||||||
|
|
||||||
consumed_indices: set[int] = set()
|
consumed_indices: set[int] = set()
|
||||||
|
|
||||||
i = 0
|
i = 0
|
||||||
while i < len(args):
|
while i < len(args):
|
||||||
token = args[i]
|
token = args[i]
|
||||||
|
@ -588,6 +671,25 @@ class CommandArgumentParser:
|
||||||
if not from_validate:
|
if not from_validate:
|
||||||
self.render_help()
|
self.render_help()
|
||||||
raise HelpSignal()
|
raise HelpSignal()
|
||||||
|
elif action == ArgumentAction.ACTION:
|
||||||
|
assert isinstance(
|
||||||
|
spec.resolver, BaseAction
|
||||||
|
), "resolver should be an instance of BaseAction"
|
||||||
|
values, new_i = self._consume_nargs(args, i + 1, spec)
|
||||||
|
try:
|
||||||
|
typed_values = [spec.type(value) for value in values]
|
||||||
|
except ValueError:
|
||||||
|
raise CommandArgumentError(
|
||||||
|
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result[spec.dest] = await spec.resolver(*typed_values)
|
||||||
|
except Exception as error:
|
||||||
|
raise CommandArgumentError(
|
||||||
|
f"[{spec.dest}] Action failed: {error}"
|
||||||
|
) from error
|
||||||
|
consumed_indices.update(range(i, new_i))
|
||||||
|
i = new_i
|
||||||
elif action == ArgumentAction.STORE_TRUE:
|
elif action == ArgumentAction.STORE_TRUE:
|
||||||
result[spec.dest] = True
|
result[spec.dest] = True
|
||||||
consumed_indices.add(i)
|
consumed_indices.add(i)
|
||||||
|
@ -609,13 +711,8 @@ class CommandArgumentParser:
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
|
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
|
||||||
)
|
)
|
||||||
if spec.nargs in (None, 1):
|
if spec.nargs is None:
|
||||||
try:
|
result[spec.dest].append(spec.type(values[0]))
|
||||||
result[spec.dest].append(spec.type(values[0]))
|
|
||||||
except ValueError:
|
|
||||||
raise CommandArgumentError(
|
|
||||||
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
result[spec.dest].append(typed_values)
|
result[spec.dest].append(typed_values)
|
||||||
consumed_indices.update(range(i, new_i))
|
consumed_indices.update(range(i, new_i))
|
||||||
|
@ -640,6 +737,10 @@ class CommandArgumentParser:
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
|
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
|
||||||
)
|
)
|
||||||
|
if not typed_values and spec.nargs not in ("*", "?"):
|
||||||
|
raise CommandArgumentError(
|
||||||
|
f"Expected at least one value for '{spec.dest}'"
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
spec.nargs in (None, 1, "?")
|
spec.nargs in (None, 1, "?")
|
||||||
and spec.action != ArgumentAction.APPEND
|
and spec.action != ArgumentAction.APPEND
|
||||||
|
@ -651,6 +752,9 @@ class CommandArgumentParser:
|
||||||
result[spec.dest] = typed_values
|
result[spec.dest] = typed_values
|
||||||
consumed_indices.update(range(i, new_i))
|
consumed_indices.update(range(i, new_i))
|
||||||
i = new_i
|
i = new_i
|
||||||
|
elif token.startswith("-"):
|
||||||
|
# Handle unrecognized option
|
||||||
|
raise CommandArgumentError(f"Unrecognized flag: {token}")
|
||||||
else:
|
else:
|
||||||
# Get the next flagged argument index if it exists
|
# Get the next flagged argument index if it exists
|
||||||
next_flagged_index = -1
|
next_flagged_index = -1
|
||||||
|
@ -660,8 +764,7 @@ class CommandArgumentParser:
|
||||||
break
|
break
|
||||||
if next_flagged_index == -1:
|
if next_flagged_index == -1:
|
||||||
next_flagged_index = len(args)
|
next_flagged_index = len(args)
|
||||||
|
args_consumed = await self._consume_all_positional_args(
|
||||||
args_consumed = self._consume_all_positional_args(
|
|
||||||
args[i:next_flagged_index],
|
args[i:next_flagged_index],
|
||||||
result,
|
result,
|
||||||
positional_args,
|
positional_args,
|
||||||
|
@ -681,26 +784,22 @@ class CommandArgumentParser:
|
||||||
f"Invalid value for {spec.dest}: must be one of {spec.choices}"
|
f"Invalid value for {spec.dest}: must be one of {spec.choices}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if spec.action == ArgumentAction.ACTION:
|
||||||
|
continue
|
||||||
|
|
||||||
if isinstance(spec.nargs, int) and spec.nargs > 1:
|
if isinstance(spec.nargs, int) and spec.nargs > 1:
|
||||||
if not isinstance(result.get(spec.dest), list):
|
assert isinstance(
|
||||||
raise CommandArgumentError(
|
result.get(spec.dest), list
|
||||||
f"Invalid value for {spec.dest}: expected a list"
|
), f"Invalid value for {spec.dest}: expected a list"
|
||||||
)
|
if not result[spec.dest] and not spec.required:
|
||||||
|
continue
|
||||||
if spec.action == ArgumentAction.APPEND:
|
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]:
|
for group in result[spec.dest]:
|
||||||
if len(group) % spec.nargs != 0:
|
if len(group) % spec.nargs != 0:
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
f"Invalid number of values for {spec.dest}: expected a multiple of {spec.nargs}"
|
f"Invalid number of values for {spec.dest}: expected a multiple of {spec.nargs}"
|
||||||
)
|
)
|
||||||
elif spec.action == ArgumentAction.EXTEND:
|
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:
|
if len(result[spec.dest]) % spec.nargs != 0:
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
f"Invalid number of values for {spec.dest}: expected a multiple of {spec.nargs}"
|
f"Invalid number of values for {spec.dest}: expected a multiple of {spec.nargs}"
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from falyx.action.base import BaseAction
|
||||||
from falyx.logger import logger
|
from falyx.logger import logger
|
||||||
from falyx.parsers.signature import infer_args_from_func
|
from falyx.parsers.signature import infer_args_from_func
|
||||||
|
|
||||||
|
@ -8,7 +9,6 @@ def same_argument_definitions(
|
||||||
actions: list[Any],
|
actions: list[Any],
|
||||||
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
|
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
|
||||||
) -> list[dict[str, Any]] | None:
|
) -> list[dict[str, Any]] | None:
|
||||||
from falyx.action.action import BaseAction
|
|
||||||
|
|
||||||
arg_sets = []
|
arg_sets = []
|
||||||
for action in actions:
|
for action in actions:
|
||||||
|
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Awaitable, Protocol, runtime_checkable
|
from typing import Any, Awaitable, Protocol, runtime_checkable
|
||||||
|
|
||||||
from falyx.action.action import BaseAction
|
from falyx.action.base import BaseAction
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
"""retry_utils.py"""
|
"""retry_utils.py"""
|
||||||
from falyx.action.action import Action, BaseAction
|
from falyx.action.action import Action
|
||||||
|
from falyx.action.base import BaseAction
|
||||||
from falyx.hook_manager import HookType
|
from falyx.hook_manager import HookType
|
||||||
from falyx.retry import RetryHandler, RetryPolicy
|
from falyx.retry import RetryHandler, RetryPolicy
|
||||||
|
|
||||||
|
|
|
@ -184,7 +184,7 @@ def setup_logging(
|
||||||
console_handler.setLevel(console_log_level)
|
console_handler.setLevel(console_log_level)
|
||||||
root.addHandler(console_handler)
|
root.addHandler(console_handler)
|
||||||
|
|
||||||
file_handler = logging.FileHandler(log_filename)
|
file_handler = logging.FileHandler(log_filename, "a", "UTF-8")
|
||||||
file_handler.setLevel(file_log_level)
|
file_handler.setLevel(file_log_level)
|
||||||
if json_log_to_file:
|
if json_log_to_file:
|
||||||
file_handler.setFormatter(
|
file_handler.setFormatter(
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
__version__ = "0.1.37"
|
__version__ = "0.1.38"
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "falyx"
|
name = "falyx"
|
||||||
version = "0.1.37"
|
version = "0.1.38"
|
||||||
description = "Reliable and introspectable async CLI action framework."
|
description = "Reliable and introspectable async CLI action framework."
|
||||||
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
|
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
|
@ -345,22 +345,24 @@ def test_add_argument_choices_invalid():
|
||||||
def test_add_argument_bad_nargs():
|
def test_add_argument_bad_nargs():
|
||||||
parser = CommandArgumentParser()
|
parser = CommandArgumentParser()
|
||||||
|
|
||||||
# ❌ Invalid nargs value
|
|
||||||
with pytest.raises(CommandArgumentError):
|
with pytest.raises(CommandArgumentError):
|
||||||
parser.add_argument("--falyx", nargs="invalid")
|
parser.add_argument("--falyx", nargs="invalid")
|
||||||
|
|
||||||
# ❌ Invalid nargs type
|
|
||||||
with pytest.raises(CommandArgumentError):
|
with pytest.raises(CommandArgumentError):
|
||||||
parser.add_argument("--falyx", nargs=123)
|
parser.add_argument("--foo", nargs="123")
|
||||||
|
|
||||||
# ❌ Invalid nargs type
|
|
||||||
with pytest.raises(CommandArgumentError):
|
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():
|
def test_add_argument_nargs():
|
||||||
parser = CommandArgumentParser()
|
parser = CommandArgumentParser()
|
||||||
# ✅ Valid nargs value
|
|
||||||
parser.add_argument("--falyx", nargs=2)
|
parser.add_argument("--falyx", nargs=2)
|
||||||
arg = parser._arguments[-1]
|
arg = parser._arguments[-1]
|
||||||
assert arg.dest == "falyx"
|
assert arg.dest == "falyx"
|
||||||
|
@ -398,8 +400,10 @@ async def test_parse_args_nargs():
|
||||||
parser = CommandArgumentParser()
|
parser = CommandArgumentParser()
|
||||||
parser.add_argument("files", nargs="+", type=str)
|
parser.add_argument("files", nargs="+", type=str)
|
||||||
parser.add_argument("mode", nargs=1)
|
parser.add_argument("mode", nargs=1)
|
||||||
|
parser.add_argument("--action", action="store_true")
|
||||||
|
|
||||||
args = await parser.parse_args(["a", "b", "c"])
|
args = await parser.parse_args(["a", "b", "c", "--action"])
|
||||||
|
args = await parser.parse_args(["--action", "a", "b", "c"])
|
||||||
|
|
||||||
assert args["files"] == ["a", "b"]
|
assert args["files"] == ["a", "b"]
|
||||||
assert args["mode"] == "c"
|
assert args["mode"] == "c"
|
||||||
|
@ -517,6 +521,15 @@ async def test_parse_args_nargs_multiple_positional():
|
||||||
await parser.parse_args([])
|
await parser.parse_args([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_args_nargs_none():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
parser.add_argument("numbers", type=int)
|
||||||
|
parser.add_argument("mode")
|
||||||
|
|
||||||
|
await parser.parse_args(["1", "2"])
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_parse_args_nargs_invalid_positional_arguments():
|
async def test_parse_args_nargs_invalid_positional_arguments():
|
||||||
parser = CommandArgumentParser()
|
parser = CommandArgumentParser()
|
||||||
|
@ -542,20 +555,78 @@ async def test_parse_args_append():
|
||||||
assert args["numbers"] == []
|
assert args["numbers"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_args_nargs_int_append():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int, nargs=1)
|
||||||
|
|
||||||
|
args = await parser.parse_args(["--numbers", "1", "--numbers", "2", "--numbers", "3"])
|
||||||
|
assert args["numbers"] == [[1], [2], [3]]
|
||||||
|
|
||||||
|
args = await parser.parse_args(["--numbers", "1"])
|
||||||
|
assert args["numbers"] == [[1]]
|
||||||
|
|
||||||
|
args = await parser.parse_args([])
|
||||||
|
assert args["numbers"] == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_parse_args_nargs_append():
|
async def test_parse_args_nargs_append():
|
||||||
parser = CommandArgumentParser()
|
parser = CommandArgumentParser()
|
||||||
parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int, nargs="*")
|
parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int, nargs="*")
|
||||||
parser.add_argument("--mode")
|
parser.add_argument("--mode")
|
||||||
|
|
||||||
|
args = await parser.parse_args(["1"])
|
||||||
|
assert args["numbers"] == [[1]]
|
||||||
|
|
||||||
args = await parser.parse_args(["1", "2", "3", "--mode", "numbers", "4", "5"])
|
args = await parser.parse_args(["1", "2", "3", "--mode", "numbers", "4", "5"])
|
||||||
assert args["numbers"] == [[1, 2, 3], [4, 5]]
|
assert args["numbers"] == [[1, 2, 3], [4, 5]]
|
||||||
|
assert args["mode"] == "numbers"
|
||||||
|
|
||||||
|
args = await parser.parse_args(["1", "2", "3"])
|
||||||
|
assert args["numbers"] == [[1, 2, 3]]
|
||||||
|
|
||||||
|
args = await parser.parse_args([])
|
||||||
|
assert args["numbers"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_args_int_optional_append():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int)
|
||||||
|
|
||||||
|
args = await parser.parse_args(["1"])
|
||||||
|
assert args["numbers"] == [1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_args_int_optional_append_multiple_values():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int)
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
await parser.parse_args(["1", "2"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_args_nargs_int_positional_append():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int, nargs=1)
|
||||||
|
|
||||||
args = await parser.parse_args(["1"])
|
args = await parser.parse_args(["1"])
|
||||||
assert args["numbers"] == [[1]]
|
assert args["numbers"] == [[1]]
|
||||||
|
|
||||||
args = await parser.parse_args([])
|
with pytest.raises(CommandArgumentError):
|
||||||
assert args["numbers"] == []
|
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
|
@pytest.mark.asyncio
|
||||||
|
@ -575,6 +646,9 @@ async def test_append_groups_nargs():
|
||||||
parsed = await 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"]]
|
assert parsed["item"] == [["a", "b"], ["c", "d"]]
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
await cap.parse_args(["--item", "a", "b", "--item", "c"])
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_extend_flattened():
|
async def test_extend_flattened():
|
||||||
|
@ -720,3 +794,35 @@ async def test_extend_positional_nargs():
|
||||||
|
|
||||||
with pytest.raises(CommandArgumentError):
|
with pytest.raises(CommandArgumentError):
|
||||||
await 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
|
||||||
|
|
|
@ -0,0 +1,227 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from falyx.action import Action, SelectionAction
|
||||||
|
from falyx.exceptions import CommandArgumentError
|
||||||
|
from falyx.parsers import ArgumentAction, CommandArgumentParser
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_argument():
|
||||||
|
"""Test the add_argument method."""
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
action = Action("test_action", lambda: "value")
|
||||||
|
parser.add_argument(
|
||||||
|
"test", action=ArgumentAction.ACTION, help="Test argument", resolver=action
|
||||||
|
)
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
parser.add_argument("test1", action=ArgumentAction.ACTION, help="Test argument")
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
parser.add_argument(
|
||||||
|
"test2",
|
||||||
|
action=ArgumentAction.ACTION,
|
||||||
|
help="Test argument",
|
||||||
|
resolver="Not an action",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_falyx_actions():
|
||||||
|
"""Test the Falyx actions."""
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
action = Action("test_action", lambda: "value")
|
||||||
|
parser.add_argument(
|
||||||
|
"-a",
|
||||||
|
"--alpha",
|
||||||
|
action=ArgumentAction.ACTION,
|
||||||
|
resolver=action,
|
||||||
|
help="Alpha option",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test valid cases
|
||||||
|
args = await parser.parse_args(["-a"])
|
||||||
|
assert args["alpha"] == "value"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_action_basic():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
action = Action("hello", lambda: "hi")
|
||||||
|
parser.add_argument("--greet", action=ArgumentAction.ACTION, resolver=action)
|
||||||
|
args = await parser.parse_args(["--greet"])
|
||||||
|
assert args["greet"] == "hi"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_action_with_nargs():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
|
||||||
|
def multiply(a, b):
|
||||||
|
return int(a) * int(b)
|
||||||
|
|
||||||
|
action = Action("multiply", multiply)
|
||||||
|
parser.add_argument("--mul", action=ArgumentAction.ACTION, resolver=action, nargs=2)
|
||||||
|
args = await parser.parse_args(["--mul", "3", "4"])
|
||||||
|
assert args["mul"] == 12
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_action_with_nargs_positional():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
|
||||||
|
def multiply(a, b):
|
||||||
|
return int(a) * int(b)
|
||||||
|
|
||||||
|
action = Action("multiply", multiply)
|
||||||
|
parser.add_argument("mul", action=ArgumentAction.ACTION, resolver=action, nargs=2)
|
||||||
|
args = await parser.parse_args(["3", "4"])
|
||||||
|
assert args["mul"] == 12
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
await parser.parse_args(["3"])
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
await parser.parse_args([])
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
await parser.parse_args(["3", "4", "5"])
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
await parser.parse_args(["--mul", "3", "4"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_action_with_nargs_positional_int():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
|
||||||
|
def multiply(a, b):
|
||||||
|
return a * b
|
||||||
|
|
||||||
|
action = Action("multiply", multiply)
|
||||||
|
parser.add_argument(
|
||||||
|
"mul", action=ArgumentAction.ACTION, resolver=action, nargs=2, type=int
|
||||||
|
)
|
||||||
|
args = await parser.parse_args(["3", "4"])
|
||||||
|
assert args["mul"] == 12
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
await parser.parse_args(["3"])
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
await parser.parse_args(["abc", "3"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_action_with_nargs_type():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
|
||||||
|
def multiply(a, b):
|
||||||
|
return a * b
|
||||||
|
|
||||||
|
action = Action("multiply", multiply)
|
||||||
|
parser.add_argument(
|
||||||
|
"--mul", action=ArgumentAction.ACTION, resolver=action, nargs=2, type=int
|
||||||
|
)
|
||||||
|
args = await parser.parse_args(["--mul", "3", "4"])
|
||||||
|
assert args["mul"] == 12
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
await parser.parse_args(["--mul", "abc", "3"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_action_with_custom_type():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
|
||||||
|
def upcase(s):
|
||||||
|
return s.upper()
|
||||||
|
|
||||||
|
action = Action("upcase", upcase)
|
||||||
|
parser.add_argument("--word", action=ArgumentAction.ACTION, resolver=action, type=str)
|
||||||
|
args = await parser.parse_args(["--word", "hello"])
|
||||||
|
assert args["word"] == "HELLO"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_action_with_nargs_star():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
|
||||||
|
def joiner(*args):
|
||||||
|
return "-".join(args)
|
||||||
|
|
||||||
|
action = Action("join", joiner)
|
||||||
|
parser.add_argument(
|
||||||
|
"--tags", action=ArgumentAction.ACTION, resolver=action, nargs="*"
|
||||||
|
)
|
||||||
|
args = await parser.parse_args(["--tags", "a", "b", "c"])
|
||||||
|
assert args["tags"] == "a-b-c"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_action_nargs_plus_missing():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
action = Action("noop", lambda *args: args)
|
||||||
|
parser.add_argument("--x", action=ArgumentAction.ACTION, resolver=action, nargs="+")
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
await parser.parse_args(["--x"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_action_with_default():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
action = Action("default", lambda value: value)
|
||||||
|
parser.add_argument(
|
||||||
|
"--default",
|
||||||
|
action=ArgumentAction.ACTION,
|
||||||
|
resolver=action,
|
||||||
|
default="default_value",
|
||||||
|
)
|
||||||
|
args = await parser.parse_args([])
|
||||||
|
assert args["default"] == "default_value"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_action_with_default_and_value():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
action = Action("default", lambda value: value)
|
||||||
|
parser.add_argument(
|
||||||
|
"--default",
|
||||||
|
action=ArgumentAction.ACTION,
|
||||||
|
resolver=action,
|
||||||
|
default="default_value",
|
||||||
|
)
|
||||||
|
args = await parser.parse_args(["--default", "new_value"])
|
||||||
|
assert args["default"] == "new_value"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_action_with_default_and_value_not():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
action = Action("default", lambda: "default_value")
|
||||||
|
parser.add_argument(
|
||||||
|
"--default",
|
||||||
|
action=ArgumentAction.ACTION,
|
||||||
|
resolver=action,
|
||||||
|
default="default_value",
|
||||||
|
)
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
await parser.parse_args(["--default", "new_value"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_action_with_default_and_value_positional():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
action = Action("default", lambda: "default_value")
|
||||||
|
parser.add_argument("default", action=ArgumentAction.ACTION, resolver=action)
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
await parser.parse_args([])
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
await parser.parse_args(["be"])
|
||||||
|
|
||||||
|
|
||||||
|
# @pytest.mark.asyncio
|
||||||
|
# async def test_selection_action():
|
||||||
|
# parser = CommandArgumentParser()
|
||||||
|
# action = SelectionAction("select", selections=["a", "b", "c"])
|
||||||
|
# parser.add_argument("--select", action=ArgumentAction.ACTION, resolver=action)
|
||||||
|
# args = await parser.parse_args(["--select"])
|
|
@ -0,0 +1,90 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from falyx.parsers import Argument, ArgumentAction
|
||||||
|
|
||||||
|
|
||||||
|
def test_positional_text_with_choices():
|
||||||
|
arg = Argument(flags=("path",), dest="path", positional=True, choices=["a", "b"])
|
||||||
|
assert arg.get_positional_text() == "{a,b}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_positional_text_without_choices():
|
||||||
|
arg = Argument(flags=("path",), dest="path", positional=True)
|
||||||
|
assert arg.get_positional_text() == "path"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"nargs,expected",
|
||||||
|
[
|
||||||
|
(None, "VALUE"),
|
||||||
|
(1, "VALUE"),
|
||||||
|
("?", "[VALUE]"),
|
||||||
|
("*", "[VALUE ...]"),
|
||||||
|
("+", "VALUE [VALUE ...]"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_choice_text_store_action_variants(nargs, expected):
|
||||||
|
arg = Argument(
|
||||||
|
flags=("--value",), dest="value", action=ArgumentAction.STORE, nargs=nargs
|
||||||
|
)
|
||||||
|
assert arg.get_choice_text() == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"nargs,expected",
|
||||||
|
[
|
||||||
|
(None, "value"),
|
||||||
|
(1, "value"),
|
||||||
|
("?", "[value]"),
|
||||||
|
("*", "[value ...]"),
|
||||||
|
("+", "value [value ...]"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_choice_text_store_action_variants_positional(nargs, expected):
|
||||||
|
arg = Argument(
|
||||||
|
flags=("value",),
|
||||||
|
dest="value",
|
||||||
|
action=ArgumentAction.STORE,
|
||||||
|
nargs=nargs,
|
||||||
|
positional=True,
|
||||||
|
)
|
||||||
|
assert arg.get_choice_text() == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_choice_text_with_choices():
|
||||||
|
arg = Argument(flags=("--mode",), dest="mode", choices=["dev", "prod"])
|
||||||
|
assert arg.get_choice_text() == "{dev,prod}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_choice_text_append_and_extend():
|
||||||
|
for action in [ArgumentAction.APPEND, ArgumentAction.EXTEND]:
|
||||||
|
arg = Argument(flags=("--tag",), dest="tag", action=action)
|
||||||
|
assert arg.get_choice_text() == "TAG"
|
||||||
|
|
||||||
|
|
||||||
|
def test_equality():
|
||||||
|
a1 = Argument(flags=("--f",), dest="f")
|
||||||
|
a2 = Argument(flags=("--f",), dest="f")
|
||||||
|
a3 = Argument(flags=("-x",), dest="x")
|
||||||
|
|
||||||
|
assert a1 == a2
|
||||||
|
assert a1 != a3
|
||||||
|
assert hash(a1) == hash(a2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_inequality_with_non_argument():
|
||||||
|
arg = Argument(flags=("--f",), dest="f")
|
||||||
|
assert arg != "not an argument"
|
||||||
|
|
||||||
|
|
||||||
|
def test_argument_equality():
|
||||||
|
arg = Argument("--foo", dest="foo", type=str, default="default_value")
|
||||||
|
arg2 = Argument("--foo", dest="foo", type=str, default="default_value")
|
||||||
|
arg3 = Argument("--bar", dest="bar", type=int, default=42)
|
||||||
|
arg4 = Argument("--foo", dest="foo", type=str, default="foobar")
|
||||||
|
assert arg == arg2
|
||||||
|
assert arg != arg3
|
||||||
|
assert arg != arg4
|
||||||
|
assert arg != "not an argument"
|
||||||
|
assert arg is not None
|
||||||
|
assert arg != object()
|
|
@ -0,0 +1,11 @@
|
||||||
|
from falyx.parsers 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()) == 8
|
|
@ -0,0 +1,56 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from falyx.exceptions import CommandArgumentError
|
||||||
|
from falyx.parsers 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"])
|
|
@ -0,0 +1,128 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from falyx.exceptions import CommandArgumentError
|
||||||
|
from falyx.parsers 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"])
|
|
@ -1,6 +1,7 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from falyx import Action, Falyx
|
from falyx import Falyx
|
||||||
|
from falyx.action import Action
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
Loading…
Reference in New Issue