Files
falyx/falyx/action/process_pool_action.py
Roland Thomas 7f63e16097 feat: Add module docs, Enum coercion, tracebacks, and toggle improvements
- Add comprehensive module docstrings across the codebase for better clarity and documentation.
- Refactor Enum classes (e.g., FileType, ConfirmType) to use `_missing_` for built-in coercion from strings.
- Add `encoding` attribute to `LoadFileAction`, `SaveFileAction`, and `SelectFileAction` for more flexible file handling.
- Enable lazy file loading by default in `SelectFileAction` to improve performance.
- Simplify bottom bar toggle behavior: all toggles now use `ctrl+<key>`, eliminating the need for key conflict checks with Falyx commands.
- Add `ignore_in_history` attribute to `Command` to refine how `ExecutionRegistry` identifies the last valid result.
- Improve History command output: now includes tracebacks when displaying exceptions.
2025-07-19 14:44:43 -04:00

236 lines
8.6 KiB
Python

# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `ProcessPoolAction`, a parallelized action executor that distributes
tasks across multiple processes using Python's `concurrent.futures.ProcessPoolExecutor`.
This module enables structured execution of CPU-bound tasks in parallel while
retaining Falyx's core guarantees: lifecycle hooks, error isolation, execution context
tracking, and introspectable previews.
Key Components:
- ProcessTask: Lightweight wrapper for a task + args/kwargs
- ProcessPoolAction: Parallel action that runs tasks concurrently in separate processes
Use this module to accelerate workflows involving expensive computation or
external resources that benefit from true parallelism.
"""
from __future__ import annotations
import asyncio
import random
from concurrent.futures import ProcessPoolExecutor
from dataclasses import dataclass, field
from functools import partial
from typing import Any, Callable, Sequence
from rich.tree import Tree
from falyx.action.base_action import BaseAction
from falyx.context import ExecutionContext, SharedContext
from falyx.exceptions import EmptyPoolError
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType
from falyx.logger import logger
from falyx.parser.utils import same_argument_definitions
from falyx.themes import OneColors
@dataclass
class ProcessTask:
"""
Represents a callable task with its arguments for parallel execution.
This lightweight container is used to queue individual tasks for execution
inside a `ProcessPoolAction`.
Attributes:
task (Callable): A function to execute.
args (tuple): Positional arguments to pass to the function.
kwargs (dict): Keyword arguments to pass to the function.
Raises:
TypeError: If `task` is not callable.
"""
task: Callable[..., Any]
args: tuple = ()
kwargs: dict[str, Any] = field(default_factory=dict)
def __post_init__(self):
if not callable(self.task):
raise TypeError(f"Expected a callable task, got {type(self.task).__name__}")
class ProcessPoolAction(BaseAction):
"""
Executes a set of independent tasks in parallel using a process pool.
`ProcessPoolAction` is ideal for CPU-bound tasks that benefit from
concurrent execution in separate processes. Each task is wrapped in a
`ProcessTask` instance and executed in a `concurrent.futures.ProcessPoolExecutor`.
Key Features:
- Parallel, process-based execution
- Hook lifecycle support across all stages
- Supports argument injection (e.g., `last_result`)
- Compatible with retry behavior and shared context propagation
- Captures all task results (including exceptions) and records execution context
Args:
name (str): Name of the action. Used for logging and debugging.
actions (Sequence[ProcessTask] | None): A list of tasks to run.
hooks (HookManager | None): Optional hook manager for lifecycle events.
executor (ProcessPoolExecutor | None): Custom executor instance (optional).
inject_last_result (bool): Whether to inject the last result into task kwargs.
inject_into (str): Name of the kwarg to use for injected result.
Returns:
list[Any]: A list of task results in submission order. Exceptions are preserved.
Raises:
EmptyPoolError: If no actions are registered.
ValueError: If injected `last_result` is not pickleable.
Example:
ProcessPoolAction(
name="ParallelTransforms",
actions=[
ProcessTask(func_a, args=(1,)),
ProcessTask(func_b, kwargs={"x": 2}),
]
)
"""
def __init__(
self,
name: str,
actions: Sequence[ProcessTask] | None = None,
*,
hooks: HookManager | None = None,
executor: ProcessPoolExecutor | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
):
super().__init__(
name,
hooks=hooks,
inject_last_result=inject_last_result,
inject_into=inject_into,
)
self.executor = executor or ProcessPoolExecutor()
self.is_retryable = True
self.actions: list[ProcessTask] = []
if actions:
self.set_actions(actions)
def set_actions(self, actions: Sequence[ProcessTask]) -> None:
"""Replaces the current action list with a new one."""
self.actions.clear()
for action in actions:
self.add_action(action)
def add_action(self, action: ProcessTask) -> None:
if not isinstance(action, ProcessTask):
raise TypeError(f"Expected a ProcessTask, got {type(action).__name__}")
self.actions.append(action)
def get_infer_target(self) -> tuple[Callable[..., Any] | None, None]:
arg_defs = same_argument_definitions([action.task for action in self.actions])
if arg_defs:
return self.actions[0].task, None
logger.debug(
"[%s] auto_args disabled: mismatched ProcessPoolAction arguments",
self.name,
)
return None, None
async def _run(self, *args, **kwargs) -> Any:
if not self.actions:
raise EmptyPoolError(f"[{self.name}] No actions to execute.")
shared_context = SharedContext(name=self.name, action=self, is_parallel=True)
if self.shared_context:
shared_context.set_shared_result(self.shared_context.last_result())
if self.inject_last_result and self.shared_context:
last_result = self.shared_context.last_result()
if not self._validate_pickleable(last_result):
raise ValueError(
f"Cannot inject last result into {self.name}: "
f"last result is not pickleable."
)
updated_kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext(
name=self.name,
args=args,
kwargs=updated_kwargs,
action=self,
)
loop = asyncio.get_running_loop()
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
futures = [
loop.run_in_executor(
self.executor,
partial(
task.task,
*(*args, *task.args),
**{**updated_kwargs, **task.kwargs},
),
)
for task in self.actions
]
results = await asyncio.gather(*futures, return_exceptions=True)
context.result = results
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return results
except Exception as error:
context.exception = error
await self.hooks.trigger(HookType.ON_ERROR, context)
if context.result is not None:
return context.result
raise
finally:
context.stop_timer()
await self.hooks.trigger(HookType.AFTER, context)
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
er.record(context)
def _validate_pickleable(self, obj: Any) -> bool:
try:
import pickle
pickle.dumps(obj)
return True
except (pickle.PicklingError, TypeError):
return False
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.DARK_YELLOW_b}]🧠 ProcessPoolAction[/] '{self.name}'"]
if self.inject_last_result:
label.append(f" [dim](receives '{self.inject_into}')[/dim]")
tree = parent.add("".join(label)) if parent else Tree("".join(label))
actions = self.actions.copy()
random.shuffle(actions)
for action in actions:
label = [
f"[{OneColors.DARK_YELLOW_b}] - {getattr(action.task, '__name__', repr(action.task))}[/] "
f"[dim]({', '.join(map(repr, action.args))})[/]"
]
if action.kwargs:
label.append(
f" [dim]({', '.join(f'{k}={v!r}' for k, v in action.kwargs.items())})[/]"
)
tree.add("".join(label))
if not parent:
self.console.print(tree)
def __str__(self) -> str:
return (
f"ProcessPoolAction(name={self.name!r}, "
f"actions={[getattr(action.task, '__name__', repr(action.task)) for action in self.actions]}, "
f"inject_last_result={self.inject_last_result}, "
f"inject_into={self.inject_into!r})"
)