falyx 0.1.37__py3-none-any.whl → 0.1.38__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- falyx/__init__.py +0 -11
- falyx/action/__init__.py +7 -9
- falyx/action/action.py +4 -724
- falyx/action/action_factory.py +1 -1
- falyx/action/action_group.py +169 -0
- falyx/action/base.py +156 -0
- falyx/action/chained_action.py +208 -0
- falyx/action/fallback_action.py +49 -0
- falyx/action/io_action.py +1 -1
- falyx/action/literal_input_action.py +47 -0
- falyx/action/menu_action.py +1 -1
- falyx/action/mixins.py +33 -0
- falyx/action/process_action.py +128 -0
- falyx/action/prompt_menu_action.py +1 -1
- falyx/action/select_file_action.py +1 -1
- falyx/action/selection_action.py +1 -2
- falyx/action/user_input_action.py +1 -1
- falyx/command.py +2 -1
- falyx/config.py +2 -1
- falyx/falyx.py +21 -13
- falyx/menu.py +1 -1
- falyx/parsers/argparse.py +139 -40
- falyx/parsers/utils.py +1 -1
- falyx/protocols.py +1 -1
- falyx/retry_utils.py +2 -1
- falyx/utils.py +1 -1
- falyx/version.py +1 -1
- {falyx-0.1.37.dist-info → falyx-0.1.38.dist-info}/METADATA +1 -1
- falyx-0.1.38.dist-info/RECORD +60 -0
- falyx-0.1.37.dist-info/RECORD +0 -53
- {falyx-0.1.37.dist-info → falyx-0.1.38.dist-info}/LICENSE +0 -0
- {falyx-0.1.37.dist-info → falyx-0.1.38.dist-info}/WHEEL +0 -0
- {falyx-0.1.37.dist-info → falyx-0.1.38.dist-info}/entry_points.txt +0 -0
falyx/action/action.py
CHANGED
@@ -1,170 +1,21 @@
|
|
1
1
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
2
|
-
"""action.py
|
3
|
-
|
4
|
-
Core action system for Falyx.
|
5
|
-
|
6
|
-
This module defines the building blocks for executable actions and workflows,
|
7
|
-
providing a structured way to compose, execute, recover, and manage sequences of
|
8
|
-
operations.
|
9
|
-
|
10
|
-
All actions are callable and follow a unified signature:
|
11
|
-
result = action(*args, **kwargs)
|
12
|
-
|
13
|
-
Core guarantees:
|
14
|
-
- Full hook lifecycle support (before, on_success, on_error, after, on_teardown).
|
15
|
-
- Consistent timing and execution context tracking for each run.
|
16
|
-
- Unified, predictable result handling and error propagation.
|
17
|
-
- Optional last_result injection to enable flexible, data-driven workflows.
|
18
|
-
- Built-in support for retries, rollbacks, parallel groups, chaining, and fallback
|
19
|
-
recovery.
|
20
|
-
|
21
|
-
Key components:
|
22
|
-
- Action: wraps a function or coroutine into a standard executable unit.
|
23
|
-
- ChainedAction: runs actions sequentially, optionally injecting last results.
|
24
|
-
- ActionGroup: runs actions in parallel and gathers results.
|
25
|
-
- ProcessAction: executes CPU-bound functions in a separate process.
|
26
|
-
- LiteralInputAction: injects static values into workflows.
|
27
|
-
- FallbackAction: gracefully recovers from failures or missing data.
|
28
|
-
|
29
|
-
This design promotes clean, fault-tolerant, modular CLI and automation systems.
|
30
|
-
"""
|
2
|
+
"""action.py"""
|
31
3
|
from __future__ import annotations
|
32
4
|
|
33
|
-
import asyncio
|
34
|
-
import random
|
35
|
-
from abc import ABC, abstractmethod
|
36
|
-
from concurrent.futures import ProcessPoolExecutor
|
37
|
-
from functools import cached_property, partial
|
38
5
|
from typing import Any, Callable
|
39
6
|
|
40
|
-
from rich.console import Console
|
41
7
|
from rich.tree import Tree
|
42
8
|
|
43
|
-
from falyx.
|
44
|
-
from falyx.
|
45
|
-
from falyx.exceptions import EmptyChainError
|
9
|
+
from falyx.action.base import BaseAction
|
10
|
+
from falyx.context import ExecutionContext
|
46
11
|
from falyx.execution_registry import ExecutionRegistry as er
|
47
|
-
from falyx.hook_manager import
|
12
|
+
from falyx.hook_manager import HookManager, HookType
|
48
13
|
from falyx.logger import logger
|
49
|
-
from falyx.options_manager import OptionsManager
|
50
|
-
from falyx.parsers.utils import same_argument_definitions
|
51
14
|
from falyx.retry import RetryHandler, RetryPolicy
|
52
15
|
from falyx.themes import OneColors
|
53
16
|
from falyx.utils import ensure_async
|
54
17
|
|
55
18
|
|
56
|
-
class BaseAction(ABC):
|
57
|
-
"""
|
58
|
-
Base class for actions. Actions can be simple functions or more
|
59
|
-
complex actions like `ChainedAction` or `ActionGroup`. They can also
|
60
|
-
be run independently or as part of Falyx.
|
61
|
-
|
62
|
-
inject_last_result (bool): Whether to inject the previous action's result
|
63
|
-
into kwargs.
|
64
|
-
inject_into (str): The name of the kwarg key to inject the result as
|
65
|
-
(default: 'last_result').
|
66
|
-
"""
|
67
|
-
|
68
|
-
def __init__(
|
69
|
-
self,
|
70
|
-
name: str,
|
71
|
-
*,
|
72
|
-
hooks: HookManager | None = None,
|
73
|
-
inject_last_result: bool = False,
|
74
|
-
inject_into: str = "last_result",
|
75
|
-
never_prompt: bool = False,
|
76
|
-
logging_hooks: bool = False,
|
77
|
-
) -> None:
|
78
|
-
self.name = name
|
79
|
-
self.hooks = hooks or HookManager()
|
80
|
-
self.is_retryable: bool = False
|
81
|
-
self.shared_context: SharedContext | None = None
|
82
|
-
self.inject_last_result: bool = inject_last_result
|
83
|
-
self.inject_into: str = inject_into
|
84
|
-
self._never_prompt: bool = never_prompt
|
85
|
-
self._skip_in_chain: bool = False
|
86
|
-
self.console = Console(color_system="auto")
|
87
|
-
self.options_manager: OptionsManager | None = None
|
88
|
-
|
89
|
-
if logging_hooks:
|
90
|
-
register_debug_hooks(self.hooks)
|
91
|
-
|
92
|
-
async def __call__(self, *args, **kwargs) -> Any:
|
93
|
-
return await self._run(*args, **kwargs)
|
94
|
-
|
95
|
-
@abstractmethod
|
96
|
-
async def _run(self, *args, **kwargs) -> Any:
|
97
|
-
raise NotImplementedError("_run must be implemented by subclasses")
|
98
|
-
|
99
|
-
@abstractmethod
|
100
|
-
async def preview(self, parent: Tree | None = None):
|
101
|
-
raise NotImplementedError("preview must be implemented by subclasses")
|
102
|
-
|
103
|
-
@abstractmethod
|
104
|
-
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
|
105
|
-
"""
|
106
|
-
Returns the callable to be used for argument inference.
|
107
|
-
By default, it returns None.
|
108
|
-
"""
|
109
|
-
raise NotImplementedError("get_infer_target must be implemented by subclasses")
|
110
|
-
|
111
|
-
def set_options_manager(self, options_manager: OptionsManager) -> None:
|
112
|
-
self.options_manager = options_manager
|
113
|
-
|
114
|
-
def set_shared_context(self, shared_context: SharedContext) -> None:
|
115
|
-
self.shared_context = shared_context
|
116
|
-
|
117
|
-
def get_option(self, option_name: str, default: Any = None) -> Any:
|
118
|
-
"""
|
119
|
-
Resolve an option from the OptionsManager if present, otherwise use the fallback.
|
120
|
-
"""
|
121
|
-
if self.options_manager:
|
122
|
-
return self.options_manager.get(option_name, default)
|
123
|
-
return default
|
124
|
-
|
125
|
-
@property
|
126
|
-
def last_result(self) -> Any:
|
127
|
-
"""Return the last result from the shared context."""
|
128
|
-
if self.shared_context:
|
129
|
-
return self.shared_context.last_result()
|
130
|
-
return None
|
131
|
-
|
132
|
-
@property
|
133
|
-
def never_prompt(self) -> bool:
|
134
|
-
return self.get_option("never_prompt", self._never_prompt)
|
135
|
-
|
136
|
-
def prepare(
|
137
|
-
self, shared_context: SharedContext, options_manager: OptionsManager | None = None
|
138
|
-
) -> BaseAction:
|
139
|
-
"""
|
140
|
-
Prepare the action specifically for sequential (ChainedAction) execution.
|
141
|
-
Can be overridden for chain-specific logic.
|
142
|
-
"""
|
143
|
-
self.set_shared_context(shared_context)
|
144
|
-
if options_manager:
|
145
|
-
self.set_options_manager(options_manager)
|
146
|
-
return self
|
147
|
-
|
148
|
-
def _maybe_inject_last_result(self, kwargs: dict[str, Any]) -> dict[str, Any]:
|
149
|
-
if self.inject_last_result and self.shared_context:
|
150
|
-
key = self.inject_into
|
151
|
-
if key in kwargs:
|
152
|
-
logger.warning("[%s] Overriding '%s' with last_result", self.name, key)
|
153
|
-
kwargs = dict(kwargs)
|
154
|
-
kwargs[key] = self.shared_context.last_result()
|
155
|
-
return kwargs
|
156
|
-
|
157
|
-
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
|
158
|
-
"""Register a hook for all actions and sub-actions."""
|
159
|
-
self.hooks.register(hook_type, hook)
|
160
|
-
|
161
|
-
async def _write_stdout(self, data: str) -> None:
|
162
|
-
"""Override in subclasses that produce terminal output."""
|
163
|
-
|
164
|
-
def __repr__(self) -> str:
|
165
|
-
return str(self)
|
166
|
-
|
167
|
-
|
168
19
|
class Action(BaseAction):
|
169
20
|
"""
|
170
21
|
Action wraps a simple function or coroutine into a standard executable unit.
|
@@ -309,574 +160,3 @@ class Action(BaseAction):
|
|
309
160
|
f"args={self.args!r}, kwargs={self.kwargs!r}, "
|
310
161
|
f"retry={self.retry_policy.enabled})"
|
311
162
|
)
|
312
|
-
|
313
|
-
|
314
|
-
class LiteralInputAction(Action):
|
315
|
-
"""
|
316
|
-
LiteralInputAction injects a static value into a ChainedAction.
|
317
|
-
|
318
|
-
This allows embedding hardcoded values mid-pipeline, useful when:
|
319
|
-
- Providing default or fallback inputs.
|
320
|
-
- Starting a pipeline with a fixed input.
|
321
|
-
- Supplying missing context manually.
|
322
|
-
|
323
|
-
Args:
|
324
|
-
value (Any): The static value to inject.
|
325
|
-
"""
|
326
|
-
|
327
|
-
def __init__(self, value: Any):
|
328
|
-
self._value = value
|
329
|
-
|
330
|
-
async def literal(*_, **__):
|
331
|
-
return value
|
332
|
-
|
333
|
-
super().__init__("Input", literal)
|
334
|
-
|
335
|
-
@cached_property
|
336
|
-
def value(self) -> Any:
|
337
|
-
"""Return the literal value."""
|
338
|
-
return self._value
|
339
|
-
|
340
|
-
async def preview(self, parent: Tree | None = None):
|
341
|
-
label = [f"[{OneColors.LIGHT_YELLOW}]📥 LiteralInput[/] '{self.name}'"]
|
342
|
-
label.append(f" [dim](value = {repr(self.value)})[/dim]")
|
343
|
-
if parent:
|
344
|
-
parent.add("".join(label))
|
345
|
-
else:
|
346
|
-
self.console.print(Tree("".join(label)))
|
347
|
-
|
348
|
-
def __str__(self) -> str:
|
349
|
-
return f"LiteralInputAction(value={self.value!r})"
|
350
|
-
|
351
|
-
|
352
|
-
class FallbackAction(Action):
|
353
|
-
"""
|
354
|
-
FallbackAction provides a default value if the previous action failed or
|
355
|
-
returned None.
|
356
|
-
|
357
|
-
It injects the last result and checks:
|
358
|
-
- If last_result is not None, it passes it through unchanged.
|
359
|
-
- If last_result is None (e.g., due to failure), it replaces it with a fallback value.
|
360
|
-
|
361
|
-
Used in ChainedAction pipelines to gracefully recover from errors or missing data.
|
362
|
-
When activated, it consumes the preceding error and allows the chain to continue
|
363
|
-
normally.
|
364
|
-
|
365
|
-
Args:
|
366
|
-
fallback (Any): The fallback value to use if last_result is None.
|
367
|
-
"""
|
368
|
-
|
369
|
-
def __init__(self, fallback: Any):
|
370
|
-
self._fallback = fallback
|
371
|
-
|
372
|
-
async def _fallback_logic(last_result):
|
373
|
-
return last_result if last_result is not None else fallback
|
374
|
-
|
375
|
-
super().__init__(name="Fallback", action=_fallback_logic, inject_last_result=True)
|
376
|
-
|
377
|
-
@cached_property
|
378
|
-
def fallback(self) -> Any:
|
379
|
-
"""Return the fallback value."""
|
380
|
-
return self._fallback
|
381
|
-
|
382
|
-
async def preview(self, parent: Tree | None = None):
|
383
|
-
label = [f"[{OneColors.LIGHT_RED}]🛟 Fallback[/] '{self.name}'"]
|
384
|
-
label.append(f" [dim](uses fallback = {repr(self.fallback)})[/dim]")
|
385
|
-
if parent:
|
386
|
-
parent.add("".join(label))
|
387
|
-
else:
|
388
|
-
self.console.print(Tree("".join(label)))
|
389
|
-
|
390
|
-
def __str__(self) -> str:
|
391
|
-
return f"FallbackAction(fallback={self.fallback!r})"
|
392
|
-
|
393
|
-
|
394
|
-
class ActionListMixin:
|
395
|
-
"""Mixin for managing a list of actions."""
|
396
|
-
|
397
|
-
def __init__(self) -> None:
|
398
|
-
self.actions: list[BaseAction] = []
|
399
|
-
|
400
|
-
def set_actions(self, actions: list[BaseAction]) -> None:
|
401
|
-
"""Replaces the current action list with a new one."""
|
402
|
-
self.actions.clear()
|
403
|
-
for action in actions:
|
404
|
-
self.add_action(action)
|
405
|
-
|
406
|
-
def add_action(self, action: BaseAction) -> None:
|
407
|
-
"""Adds an action to the list."""
|
408
|
-
self.actions.append(action)
|
409
|
-
|
410
|
-
def remove_action(self, name: str) -> None:
|
411
|
-
"""Removes an action by name."""
|
412
|
-
self.actions = [action for action in self.actions if action.name != name]
|
413
|
-
|
414
|
-
def has_action(self, name: str) -> bool:
|
415
|
-
"""Checks if an action with the given name exists."""
|
416
|
-
return any(action.name == name for action in self.actions)
|
417
|
-
|
418
|
-
def get_action(self, name: str) -> BaseAction | None:
|
419
|
-
"""Retrieves an action by name."""
|
420
|
-
for action in self.actions:
|
421
|
-
if action.name == name:
|
422
|
-
return action
|
423
|
-
return None
|
424
|
-
|
425
|
-
|
426
|
-
class ChainedAction(BaseAction, ActionListMixin):
|
427
|
-
"""
|
428
|
-
ChainedAction executes a sequence of actions one after another.
|
429
|
-
|
430
|
-
Features:
|
431
|
-
- Supports optional automatic last_result injection (auto_inject).
|
432
|
-
- Recovers from intermediate errors using FallbackAction if present.
|
433
|
-
- Rolls back all previously executed actions if a failure occurs.
|
434
|
-
- Handles literal values with LiteralInputAction.
|
435
|
-
|
436
|
-
Best used for defining robust, ordered workflows where each step can depend on
|
437
|
-
previous results.
|
438
|
-
|
439
|
-
Args:
|
440
|
-
name (str): Name of the chain.
|
441
|
-
actions (list): List of actions or literals to execute.
|
442
|
-
hooks (HookManager, optional): Hooks for lifecycle events.
|
443
|
-
inject_last_result (bool, optional): Whether to inject last results into kwargs
|
444
|
-
by default.
|
445
|
-
inject_into (str, optional): Key name for injection.
|
446
|
-
auto_inject (bool, optional): Auto-enable injection for subsequent actions.
|
447
|
-
return_list (bool, optional): Whether to return a list of all results. False
|
448
|
-
returns the last result.
|
449
|
-
"""
|
450
|
-
|
451
|
-
def __init__(
|
452
|
-
self,
|
453
|
-
name: str,
|
454
|
-
actions: list[BaseAction | Any] | None = None,
|
455
|
-
*,
|
456
|
-
hooks: HookManager | None = None,
|
457
|
-
inject_last_result: bool = False,
|
458
|
-
inject_into: str = "last_result",
|
459
|
-
auto_inject: bool = False,
|
460
|
-
return_list: bool = False,
|
461
|
-
) -> None:
|
462
|
-
super().__init__(
|
463
|
-
name,
|
464
|
-
hooks=hooks,
|
465
|
-
inject_last_result=inject_last_result,
|
466
|
-
inject_into=inject_into,
|
467
|
-
)
|
468
|
-
ActionListMixin.__init__(self)
|
469
|
-
self.auto_inject = auto_inject
|
470
|
-
self.return_list = return_list
|
471
|
-
if actions:
|
472
|
-
self.set_actions(actions)
|
473
|
-
|
474
|
-
def _wrap_if_needed(self, action: BaseAction | Any) -> BaseAction:
|
475
|
-
if isinstance(action, BaseAction):
|
476
|
-
return action
|
477
|
-
elif callable(action):
|
478
|
-
return Action(name=action.__name__, action=action)
|
479
|
-
else:
|
480
|
-
return LiteralInputAction(action)
|
481
|
-
|
482
|
-
def add_action(self, action: BaseAction | Any) -> None:
|
483
|
-
action = self._wrap_if_needed(action)
|
484
|
-
if self.actions and self.auto_inject and not action.inject_last_result:
|
485
|
-
action.inject_last_result = True
|
486
|
-
super().add_action(action)
|
487
|
-
if hasattr(action, "register_teardown") and callable(action.register_teardown):
|
488
|
-
action.register_teardown(self.hooks)
|
489
|
-
|
490
|
-
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
|
491
|
-
if self.actions:
|
492
|
-
return self.actions[0].get_infer_target()
|
493
|
-
return None, None
|
494
|
-
|
495
|
-
def _clear_args(self):
|
496
|
-
return (), {}
|
497
|
-
|
498
|
-
async def _run(self, *args, **kwargs) -> list[Any]:
|
499
|
-
if not self.actions:
|
500
|
-
raise EmptyChainError(f"[{self.name}] No actions to execute.")
|
501
|
-
|
502
|
-
shared_context = SharedContext(name=self.name, action=self)
|
503
|
-
if self.shared_context:
|
504
|
-
shared_context.add_result(self.shared_context.last_result())
|
505
|
-
updated_kwargs = self._maybe_inject_last_result(kwargs)
|
506
|
-
context = ExecutionContext(
|
507
|
-
name=self.name,
|
508
|
-
args=args,
|
509
|
-
kwargs=updated_kwargs,
|
510
|
-
action=self,
|
511
|
-
extra={"results": [], "rollback_stack": []},
|
512
|
-
shared_context=shared_context,
|
513
|
-
)
|
514
|
-
context.start_timer()
|
515
|
-
try:
|
516
|
-
await self.hooks.trigger(HookType.BEFORE, context)
|
517
|
-
|
518
|
-
for index, action in enumerate(self.actions):
|
519
|
-
if action._skip_in_chain:
|
520
|
-
logger.debug(
|
521
|
-
"[%s] Skipping consumed action '%s'", self.name, action.name
|
522
|
-
)
|
523
|
-
continue
|
524
|
-
shared_context.current_index = index
|
525
|
-
prepared = action.prepare(shared_context, self.options_manager)
|
526
|
-
try:
|
527
|
-
result = await prepared(*args, **updated_kwargs)
|
528
|
-
except Exception as error:
|
529
|
-
if index + 1 < len(self.actions) and isinstance(
|
530
|
-
self.actions[index + 1], FallbackAction
|
531
|
-
):
|
532
|
-
logger.warning(
|
533
|
-
"[%s] Fallback triggered: %s, recovering with fallback "
|
534
|
-
"'%s'.",
|
535
|
-
self.name,
|
536
|
-
error,
|
537
|
-
self.actions[index + 1].name,
|
538
|
-
)
|
539
|
-
shared_context.add_result(None)
|
540
|
-
context.extra["results"].append(None)
|
541
|
-
fallback = self.actions[index + 1].prepare(shared_context)
|
542
|
-
result = await fallback()
|
543
|
-
fallback._skip_in_chain = True
|
544
|
-
else:
|
545
|
-
raise
|
546
|
-
args, updated_kwargs = self._clear_args()
|
547
|
-
shared_context.add_result(result)
|
548
|
-
context.extra["results"].append(result)
|
549
|
-
context.extra["rollback_stack"].append(prepared)
|
550
|
-
|
551
|
-
all_results = context.extra["results"]
|
552
|
-
assert (
|
553
|
-
all_results
|
554
|
-
), f"[{self.name}] No results captured. Something seriously went wrong."
|
555
|
-
context.result = all_results if self.return_list else all_results[-1]
|
556
|
-
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
557
|
-
return context.result
|
558
|
-
|
559
|
-
except Exception as error:
|
560
|
-
context.exception = error
|
561
|
-
shared_context.add_error(shared_context.current_index, error)
|
562
|
-
await self._rollback(context.extra["rollback_stack"], *args, **kwargs)
|
563
|
-
await self.hooks.trigger(HookType.ON_ERROR, context)
|
564
|
-
raise
|
565
|
-
finally:
|
566
|
-
context.stop_timer()
|
567
|
-
await self.hooks.trigger(HookType.AFTER, context)
|
568
|
-
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
569
|
-
er.record(context)
|
570
|
-
|
571
|
-
async def _rollback(self, rollback_stack, *args, **kwargs):
|
572
|
-
"""
|
573
|
-
Roll back all executed actions in reverse order.
|
574
|
-
|
575
|
-
Rollbacks run even if a fallback recovered from failure,
|
576
|
-
ensuring consistent undo of all side effects.
|
577
|
-
|
578
|
-
Actions without rollback handlers are skipped.
|
579
|
-
|
580
|
-
Args:
|
581
|
-
rollback_stack (list): Actions to roll back.
|
582
|
-
*args, **kwargs: Passed to rollback handlers.
|
583
|
-
"""
|
584
|
-
for action in reversed(rollback_stack):
|
585
|
-
rollback = getattr(action, "rollback", None)
|
586
|
-
if rollback:
|
587
|
-
try:
|
588
|
-
logger.warning("[%s] Rolling back...", action.name)
|
589
|
-
await action.rollback(*args, **kwargs)
|
590
|
-
except Exception as error:
|
591
|
-
logger.error("[%s] Rollback failed: %s", action.name, error)
|
592
|
-
|
593
|
-
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
|
594
|
-
"""Register a hook for all actions and sub-actions."""
|
595
|
-
self.hooks.register(hook_type, hook)
|
596
|
-
for action in self.actions:
|
597
|
-
action.register_hooks_recursively(hook_type, hook)
|
598
|
-
|
599
|
-
async def preview(self, parent: Tree | None = None):
|
600
|
-
label = [f"[{OneColors.CYAN_b}]⛓ ChainedAction[/] '{self.name}'"]
|
601
|
-
if self.inject_last_result:
|
602
|
-
label.append(f" [dim](injects '{self.inject_into}')[/dim]")
|
603
|
-
tree = parent.add("".join(label)) if parent else Tree("".join(label))
|
604
|
-
for action in self.actions:
|
605
|
-
await action.preview(parent=tree)
|
606
|
-
if not parent:
|
607
|
-
self.console.print(tree)
|
608
|
-
|
609
|
-
def __str__(self):
|
610
|
-
return (
|
611
|
-
f"ChainedAction(name={self.name!r}, "
|
612
|
-
f"actions={[a.name for a in self.actions]!r}, "
|
613
|
-
f"auto_inject={self.auto_inject}, return_list={self.return_list})"
|
614
|
-
)
|
615
|
-
|
616
|
-
|
617
|
-
class ActionGroup(BaseAction, ActionListMixin):
|
618
|
-
"""
|
619
|
-
ActionGroup executes multiple actions concurrently in parallel.
|
620
|
-
|
621
|
-
It is ideal for independent tasks that can be safely run simultaneously,
|
622
|
-
improving overall throughput and responsiveness of workflows.
|
623
|
-
|
624
|
-
Core features:
|
625
|
-
- Parallel execution of all contained actions.
|
626
|
-
- Shared last_result injection across all actions if configured.
|
627
|
-
- Aggregated collection of individual results as (name, result) pairs.
|
628
|
-
- Hook lifecycle support (before, on_success, on_error, after, on_teardown).
|
629
|
-
- Error aggregation: captures all action errors and reports them together.
|
630
|
-
|
631
|
-
Behavior:
|
632
|
-
- If any action fails, the group collects the errors but continues executing
|
633
|
-
other actions without interruption.
|
634
|
-
- After all actions complete, ActionGroup raises a single exception summarizing
|
635
|
-
all failures, or returns all results if successful.
|
636
|
-
|
637
|
-
Best used for:
|
638
|
-
- Batch processing multiple independent tasks.
|
639
|
-
- Reducing latency for workflows with parallelizable steps.
|
640
|
-
- Isolating errors while maximizing successful execution.
|
641
|
-
|
642
|
-
Args:
|
643
|
-
name (str): Name of the chain.
|
644
|
-
actions (list): List of actions or literals to execute.
|
645
|
-
hooks (HookManager, optional): Hooks for lifecycle events.
|
646
|
-
inject_last_result (bool, optional): Whether to inject last results into kwargs
|
647
|
-
by default.
|
648
|
-
inject_into (str, optional): Key name for injection.
|
649
|
-
"""
|
650
|
-
|
651
|
-
def __init__(
|
652
|
-
self,
|
653
|
-
name: str,
|
654
|
-
actions: list[BaseAction] | None = None,
|
655
|
-
*,
|
656
|
-
hooks: HookManager | None = None,
|
657
|
-
inject_last_result: bool = False,
|
658
|
-
inject_into: str = "last_result",
|
659
|
-
):
|
660
|
-
super().__init__(
|
661
|
-
name,
|
662
|
-
hooks=hooks,
|
663
|
-
inject_last_result=inject_last_result,
|
664
|
-
inject_into=inject_into,
|
665
|
-
)
|
666
|
-
ActionListMixin.__init__(self)
|
667
|
-
if actions:
|
668
|
-
self.set_actions(actions)
|
669
|
-
|
670
|
-
def _wrap_if_needed(self, action: BaseAction | Any) -> BaseAction:
|
671
|
-
if isinstance(action, BaseAction):
|
672
|
-
return action
|
673
|
-
elif callable(action):
|
674
|
-
return Action(name=action.__name__, action=action)
|
675
|
-
else:
|
676
|
-
raise TypeError(
|
677
|
-
"ActionGroup only accepts BaseAction or callable, got "
|
678
|
-
f"{type(action).__name__}"
|
679
|
-
)
|
680
|
-
|
681
|
-
def add_action(self, action: BaseAction | Any) -> None:
|
682
|
-
action = self._wrap_if_needed(action)
|
683
|
-
super().add_action(action)
|
684
|
-
if hasattr(action, "register_teardown") and callable(action.register_teardown):
|
685
|
-
action.register_teardown(self.hooks)
|
686
|
-
|
687
|
-
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
|
688
|
-
arg_defs = same_argument_definitions(self.actions)
|
689
|
-
if arg_defs:
|
690
|
-
return self.actions[0].get_infer_target()
|
691
|
-
logger.debug(
|
692
|
-
"[%s] auto_args disabled: mismatched ActionGroup arguments",
|
693
|
-
self.name,
|
694
|
-
)
|
695
|
-
return None, None
|
696
|
-
|
697
|
-
async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]:
|
698
|
-
shared_context = SharedContext(name=self.name, action=self, is_parallel=True)
|
699
|
-
if self.shared_context:
|
700
|
-
shared_context.set_shared_result(self.shared_context.last_result())
|
701
|
-
updated_kwargs = self._maybe_inject_last_result(kwargs)
|
702
|
-
context = ExecutionContext(
|
703
|
-
name=self.name,
|
704
|
-
args=args,
|
705
|
-
kwargs=updated_kwargs,
|
706
|
-
action=self,
|
707
|
-
extra={"results": [], "errors": []},
|
708
|
-
shared_context=shared_context,
|
709
|
-
)
|
710
|
-
|
711
|
-
async def run_one(action: BaseAction):
|
712
|
-
try:
|
713
|
-
prepared = action.prepare(shared_context, self.options_manager)
|
714
|
-
result = await prepared(*args, **updated_kwargs)
|
715
|
-
shared_context.add_result((action.name, result))
|
716
|
-
context.extra["results"].append((action.name, result))
|
717
|
-
except Exception as error:
|
718
|
-
shared_context.add_error(shared_context.current_index, error)
|
719
|
-
context.extra["errors"].append((action.name, error))
|
720
|
-
|
721
|
-
context.start_timer()
|
722
|
-
try:
|
723
|
-
await self.hooks.trigger(HookType.BEFORE, context)
|
724
|
-
await asyncio.gather(*[run_one(a) for a in self.actions])
|
725
|
-
|
726
|
-
if context.extra["errors"]:
|
727
|
-
context.exception = Exception(
|
728
|
-
f"{len(context.extra['errors'])} action(s) failed: "
|
729
|
-
f"{' ,'.join(name for name, _ in context.extra['errors'])}"
|
730
|
-
)
|
731
|
-
await self.hooks.trigger(HookType.ON_ERROR, context)
|
732
|
-
raise context.exception
|
733
|
-
|
734
|
-
context.result = context.extra["results"]
|
735
|
-
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
736
|
-
return context.result
|
737
|
-
|
738
|
-
except Exception as error:
|
739
|
-
context.exception = error
|
740
|
-
raise
|
741
|
-
finally:
|
742
|
-
context.stop_timer()
|
743
|
-
await self.hooks.trigger(HookType.AFTER, context)
|
744
|
-
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
745
|
-
er.record(context)
|
746
|
-
|
747
|
-
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
|
748
|
-
"""Register a hook for all actions and sub-actions."""
|
749
|
-
super().register_hooks_recursively(hook_type, hook)
|
750
|
-
for action in self.actions:
|
751
|
-
action.register_hooks_recursively(hook_type, hook)
|
752
|
-
|
753
|
-
async def preview(self, parent: Tree | None = None):
|
754
|
-
label = [f"[{OneColors.MAGENTA_b}]⏩ ActionGroup (parallel)[/] '{self.name}'"]
|
755
|
-
if self.inject_last_result:
|
756
|
-
label.append(f" [dim](receives '{self.inject_into}')[/dim]")
|
757
|
-
tree = parent.add("".join(label)) if parent else Tree("".join(label))
|
758
|
-
actions = self.actions.copy()
|
759
|
-
random.shuffle(actions)
|
760
|
-
await asyncio.gather(*(action.preview(parent=tree) for action in actions))
|
761
|
-
if not parent:
|
762
|
-
self.console.print(tree)
|
763
|
-
|
764
|
-
def __str__(self):
|
765
|
-
return (
|
766
|
-
f"ActionGroup(name={self.name!r}, actions={[a.name for a in self.actions]!r},"
|
767
|
-
f" inject_last_result={self.inject_last_result})"
|
768
|
-
)
|
769
|
-
|
770
|
-
|
771
|
-
class ProcessAction(BaseAction):
|
772
|
-
"""
|
773
|
-
ProcessAction runs a function in a separate process using ProcessPoolExecutor.
|
774
|
-
|
775
|
-
Features:
|
776
|
-
- Executes CPU-bound or blocking tasks without blocking the main event loop.
|
777
|
-
- Supports last_result injection into the subprocess.
|
778
|
-
- Validates that last_result is pickleable when injection is enabled.
|
779
|
-
|
780
|
-
Args:
|
781
|
-
name (str): Name of the action.
|
782
|
-
func (Callable): Function to execute in a new process.
|
783
|
-
args (tuple, optional): Positional arguments.
|
784
|
-
kwargs (dict, optional): Keyword arguments.
|
785
|
-
hooks (HookManager, optional): Hook manager for lifecycle events.
|
786
|
-
executor (ProcessPoolExecutor, optional): Custom executor if desired.
|
787
|
-
inject_last_result (bool, optional): Inject last result into the function.
|
788
|
-
inject_into (str, optional): Name of the injected key.
|
789
|
-
"""
|
790
|
-
|
791
|
-
def __init__(
|
792
|
-
self,
|
793
|
-
name: str,
|
794
|
-
action: Callable[..., Any],
|
795
|
-
*,
|
796
|
-
args: tuple = (),
|
797
|
-
kwargs: dict[str, Any] | None = None,
|
798
|
-
hooks: HookManager | None = None,
|
799
|
-
executor: ProcessPoolExecutor | None = None,
|
800
|
-
inject_last_result: bool = False,
|
801
|
-
inject_into: str = "last_result",
|
802
|
-
):
|
803
|
-
super().__init__(
|
804
|
-
name,
|
805
|
-
hooks=hooks,
|
806
|
-
inject_last_result=inject_last_result,
|
807
|
-
inject_into=inject_into,
|
808
|
-
)
|
809
|
-
self.action = action
|
810
|
-
self.args = args
|
811
|
-
self.kwargs = kwargs or {}
|
812
|
-
self.executor = executor or ProcessPoolExecutor()
|
813
|
-
self.is_retryable = True
|
814
|
-
|
815
|
-
def get_infer_target(self) -> tuple[Callable[..., Any] | None, None]:
|
816
|
-
return self.action, None
|
817
|
-
|
818
|
-
async def _run(self, *args, **kwargs) -> Any:
|
819
|
-
if self.inject_last_result and self.shared_context:
|
820
|
-
last_result = self.shared_context.last_result()
|
821
|
-
if not self._validate_pickleable(last_result):
|
822
|
-
raise ValueError(
|
823
|
-
f"Cannot inject last result into {self.name}: "
|
824
|
-
f"last result is not pickleable."
|
825
|
-
)
|
826
|
-
combined_args = args + self.args
|
827
|
-
combined_kwargs = self._maybe_inject_last_result({**self.kwargs, **kwargs})
|
828
|
-
context = ExecutionContext(
|
829
|
-
name=self.name,
|
830
|
-
args=combined_args,
|
831
|
-
kwargs=combined_kwargs,
|
832
|
-
action=self,
|
833
|
-
)
|
834
|
-
loop = asyncio.get_running_loop()
|
835
|
-
|
836
|
-
context.start_timer()
|
837
|
-
try:
|
838
|
-
await self.hooks.trigger(HookType.BEFORE, context)
|
839
|
-
result = await loop.run_in_executor(
|
840
|
-
self.executor, partial(self.action, *combined_args, **combined_kwargs)
|
841
|
-
)
|
842
|
-
context.result = result
|
843
|
-
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
844
|
-
return result
|
845
|
-
except Exception as error:
|
846
|
-
context.exception = error
|
847
|
-
await self.hooks.trigger(HookType.ON_ERROR, context)
|
848
|
-
if context.result is not None:
|
849
|
-
return context.result
|
850
|
-
raise
|
851
|
-
finally:
|
852
|
-
context.stop_timer()
|
853
|
-
await self.hooks.trigger(HookType.AFTER, context)
|
854
|
-
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
855
|
-
er.record(context)
|
856
|
-
|
857
|
-
def _validate_pickleable(self, obj: Any) -> bool:
|
858
|
-
try:
|
859
|
-
import pickle
|
860
|
-
|
861
|
-
pickle.dumps(obj)
|
862
|
-
return True
|
863
|
-
except (pickle.PicklingError, TypeError):
|
864
|
-
return False
|
865
|
-
|
866
|
-
async def preview(self, parent: Tree | None = None):
|
867
|
-
label = [
|
868
|
-
f"[{OneColors.DARK_YELLOW_b}]🧠 ProcessAction (new process)[/] '{self.name}'"
|
869
|
-
]
|
870
|
-
if self.inject_last_result:
|
871
|
-
label.append(f" [dim](injects '{self.inject_into}')[/dim]")
|
872
|
-
if parent:
|
873
|
-
parent.add("".join(label))
|
874
|
-
else:
|
875
|
-
self.console.print(Tree("".join(label)))
|
876
|
-
|
877
|
-
def __str__(self) -> str:
|
878
|
-
return (
|
879
|
-
f"ProcessAction(name={self.name!r}, "
|
880
|
-
f"action={getattr(self.action, '__name__', repr(self.action))}, "
|
881
|
-
f"args={self.args!r}, kwargs={self.kwargs!r})"
|
882
|
-
)
|