53 Commits

Author SHA1 Message Date
3b2c33d28f feat(help & spinners): improve help rendering, async spinner handling, and pipeline demo
- Refactored `Command.help_signature` to return `(usage, description, tags)` instead of a Rich `Padding`/`Panel`.
- Replaced `show_help()` with `render_help()` in `Command` and `Falyx`.
- Updated Falyx help rendering to use Rich `Panel`/`Padding` consistently for cleaner UI.
- Swapped `print()` calls for `console.print()` for styled output.
- Added hooks to `ProcessAction` to announce analysis start/finish.
- Added spinners to test and deploy steps; simplified retry setup.
- Converted `remove()` to `async def remove()` for consistency.
- Added async lock to prevent concurrent Live loop start/stop races.
- Added debug logging when starting/stopping the Live loop.
- Updated `spinner_teardown_hook` to `await sm.remove(...)` to align with async `remove()`.
- Removed `rich.panel`/`rich.padding` from `Command` since panels are now built in `Falyx` help rendering.
- Bumped `rich` dependency to `^14.0`.
- Bumped version to 0.1.78.

This commit polishes help display, demo UX, and spinner lifecycle safety—making spinners thread/async safe and help output more structured and readable.
2025-07-30 22:24:55 -04:00
f37aee568d feat(spinners): integrate SpinnerManager and per-action spinners into Falyx
- Added new `SpinnerManager` module for centralized spinner rendering using Rich `Live`.
- Introduced `spinner`, `spinner_message`, `spinner_type`, `spinner_style`, and `spinner_speed` to `BaseAction` and subclasses (`Action`, `ProcessAction`, `HTTPAction`, `ActionGroup`, `ChainedAction`).
- Registered `spinner_before_hook` and `spinner_teardown_hook` automatically when `spinner=True`.
- Reworked `Command` spinner logic to use the new hook-based system instead of `console.status`.
- Updated `OptionsManager` to include a `SpinnerManager` instance for global state.
- Enhanced pipeline demo to showcase spinners across chained and grouped actions.
- Bumped version to 0.1.77.

This commit unifies spinner handling across commands, actions, and groups, making spinners consistent and automatically managed by hooks.
2025-07-28 22:15:36 -04:00
8a0a45e17f feat(falyx): add persistent prompt history, multiline input support, and new enum tests
- Added `enable_prompt_history` & `prompt_history_base_dir` to Falyx, using FileHistory to persist inputs to `~/.{program}_history`
- Added `multiline` option to UserInputAction and passed through to PromptSession
- Updated examples (`argument_examples.py`, `confirm_example.py`) to enable prompt history and add TLDR examples
- Improved CLI usage tips for clarity (`[COMMAND]` instead of `[KEY]`)
- Added `test_action_types.py` and expanded `test_main.py` for init and parser coverage
- Bumped version to 0.1.76
2025-07-27 14:00:51 -04:00
da38f6d6ee style(help): improve help display layout and styling for consistency
- Wrapped help signatures in `Padding` for better visual spacing.
- Updated descriptions and tags to use dimmed text with indentation for clarity.
- Added a bold "help:" header to `_show_help()` for clearer section labeling.
- Bumped version to 0.1.75.
2025-07-26 18:07:31 -04:00
7836ff4dfd feat(help): add dynamic tip system to _show_help output
- Added `get_tip()` method to provide rotating contextual tips for help output,
  with different pools for CLI mode vs menu mode.
- Introduced `is_cli_mode` property to centralize CLI mode detection.
- Updated `_show_help()` to print a randomly selected tip instead of a static line.
- Enhanced tips with Rich formatting (bold/italic) for emphasis.
- Bumped version to 0.1.74.
2025-07-26 17:33:08 -04:00
7dca416346 feat(help): enhance CLI help rendering with Panels and TLDR validation
- Updated `Command.help_signature` to return a `(Panel, description)` tuple,
  enabling richer Rich-based help output with formatted panels.
- Integrated `program` context into commands to display accurate CLI invocation
  (`falyx run …`) depending on mode (RUN, PREVIEW, RUN_ALL).
- Refactored `_show_help` to print `Panel`-styled usage and descriptions instead
  of table rows.
- Added `program` and `options_manager` propagation to built-in commands
  (Exit, History, Help) for consistent CLI display.
- Improved `CommandArgumentParser.add_tldr_examples()` with stricter validation
  (`all()` instead of `any()`), and added new TLDR tests for coverage.
- Simplified parser epilog text to `Tip: Use 'falyx run ?' to show available commands.`
- Added tests for required `Argument` fields and TLDR examples.
- Bumped version to 0.1.73.
2025-07-26 16:14:09 -04:00
734f7b5962 feat(parser): improve choice validation and completion for flagged arguments
- Added `_check_if_in_choices()` to enforce `choices` validation for all nargs modes,
  including after resolver-based and list-based inputs.
- Enhanced `FalyxCompleter` to quote multi-word completions for better UX.
- Improved completion filtering logic to suppress stale suggestions when flag values
  are already consumed.
- Moved `ArgumentState` and `TLDRExample` to `parser_types.py` for reuse.
- Bumped version to 0.1.72.
2025-07-24 21:00:11 -04:00
489d730755 Fix TLDR causing Command not to run, Add placeholder prompt menu to Falyx 2025-07-23 19:42:44 -04:00
825ff60f08 Tweak TLDR visual formatting, Fix applying RichText to prompts 2025-07-23 00:05:09 -04:00
fa5e2a4c2c feat: add TLDR ArgumentAction and Rich-compatible prompt styling
- Introduce `ArgumentAction.TLDR` for showing concise usage examples
- Add `rich_text_to_prompt_text()` to support Rich-style markup in all prompt_toolkit inputs
- Migrate all prompt-based Actions to use `prompt_message` with Rich styling support
- Standardize `CancelSignal` as the default interrupt behavior for prompt-driven Actions
2025-07-22 21:56:44 -04:00
de53c889a6 Fix completion bug, ensure_async callback 2025-07-21 00:11:21 -04:00
0319058531 Remove default traceback logging to screen 2025-07-20 12:35:08 -04:00
5769882afd Add reserved ctrl keys to BottomBar, Add traceback support in History for --last-result 2025-07-20 11:15:09 -04:00
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
21402bff9a Fix never_prompt precedence in BaseAction, Allow default_selection to be resolved from index, keys, values, or description 2025-07-18 21:51:25 -04:00
fddc3ea8d9 Merge pull request 'completions' (#4) from completions into main
Reviewed-on: #4
2025-07-17 20:16:19 -04:00
9b9f6434a4 Add completions, Add suggestions list to Argument 2025-07-17 20:09:29 -04:00
c15e3afa5e Working on completions 2025-07-16 18:55:22 -04:00
dc1764e752 Add args, kwargs to ChainedAction, ActionGroup, Add type_word_cancel and acknowledge ConfirmTypes, update ChainedAction rollback logic 2025-07-16 18:54:03 -04:00
2288015cf3 Add message when lazy_resolver action has no input, Change ConfirmAction.confirm -> ConfirmAction.never_prompt, Move ConfirmType to action_types.py 2025-07-15 20:00:03 -04:00
68d7d89d64 Add ArgumentAction.STORE_BOOL_OPTIONAL, Add BreakChainSignal 2025-07-14 21:59:12 -04:00
9654b9926c ConfirmAction message formatting 2025-07-13 22:38:58 -04:00
294bbc9062 Add data, create_dirs to SaveFileAction 2025-07-12 21:12:34 -04:00
4c1498121f Add falyx.console for single rich.console.Console instance, Add ConfirmAction, SaveFileAction, Add lazy evaluation for ArgumentAction.ACTION 2025-07-12 11:52:02 -04:00
ed42f6488e Add FalyxCompleter, Add check for valid directory for SelectFileAction, Add more detail to error messages in CommandArgumentParser, Don't initialize CAP if a custom parser is used 2025-07-03 00:58:57 -04:00
e2f0bf5903 Remove print statements 2025-06-29 22:45:11 -04:00
bb325684ac Add LoadFileAction, Rename ActionFactoryAction->ActionFactory, Rename falyx.action.mixins->falyx.action.action_mixins, fix bug unable to parse negative numbers in CommandArgumentParser 2025-06-27 22:33:14 -04:00
38f5f1e934 Rename falyx.action.types.FileReturnType -> falyx.action.action_types.FileType, falyx.action.base -> falyx.action.base_action, argparse tweaks for custom cli programs 2025-06-10 23:03:09 -04:00
2d1177e820 Update command import 2025-06-08 14:45:41 -04:00
3c7ef3eb1c Move ShellAction to action/shell_action.py, Move Argument, ArgumentAction, and CommandArgumentParser to seperate files 2025-06-08 14:31:24 -04:00
53ba6a896a Add multi selecto to SelectionAction and SelectFileAction, Allow IOActions to receive no input, Rename subpackage falyx.parsers -> falyx.parser, Add default_text to UserInputAction 2025-06-08 12:09:16 -04:00
b24079ea7e Add ExecutionContext.signature, fix partial command matching with arguments, fix passing args to Falyx._create_context helper 2025-06-05 17:23:27 -04:00
ac82076511 Add filtering and options for History Command 2025-06-03 23:07:50 -04:00
09eeb90dc6 Bubble up errors from CAP, catch a broader exception when parsing arguments, add type parsing to arg_metadata 2025-06-02 23:45:37 -04:00
e3ebc1b17b Fix validation for empty input 2025-06-01 23:12:53 -04:00
079bc0ee77 Normalize epilogue -> epilog, allow version to be modifiable, don't allow empty input in repl 2025-06-01 23:02:35 -04:00
1c97857cb8 Centralize CAP creation in Command, Add better default type coercion 2025-06-01 17:38:48 -04:00
21af003bc7 Update help formatting, allow help to be filtered by tag 2025-05-31 21:51:08 -04:00
1585098513 Add init init-global to subparsers 2025-05-31 09:29:24 -04:00
3d3a706784 Formatting of help text 2025-05-30 21:52:29 -04:00
c2eb854e5a Add help_text for commands to argparse run subcommand, change the way Falyx.run works and you can only pass FalyxParsers 2025-05-30 00:36:55 -04:00
8a3c1d6cc8 Fix global-init imports, passing args from command line to required commands 2025-05-28 17:11:26 -04:00
f196e38c57 Add ProcessPoolAction, update CAP to look only at keywords correctly 2025-05-28 00:58:50 -04:00
fb1ffbe9f6 Add ArgumentAction.ACTION, support POSIX bundling in CAP, Move all Actions to their own file 2025-05-25 19:25:32 -04:00
429b434566 Remove emojis from logging statements 2025-05-24 17:53:34 -04:00
4f3632bc6b Remove emojis from logging statements 2025-05-24 15:09:39 -04:00
ba562168aa hotfix syntax error < python3.12 2025-05-24 13:46:07 -04:00
ddb78bd5a7 Add PromptMenuAction, add cancel button to SelectionAction, make get_command async, add Action validation and defauilt nargs to None. 2025-05-24 12:29:16 -04:00
b0c0e7dc16 Fix run_group parser for REMAINDER, fix render_help formatting 2025-05-22 14:59:16 -04:00
0a1ba22a3d Remove args/kwargs being passed to generated Action from FactoryAction 2025-05-21 23:35:57 -04:00
b51ba87999 Add cancel for SelectionActions, Add args/kwargs to ActionFactoryAction, remove requires_input detection, add return types to SelectionAction, add option to hide_menu_table 2025-05-21 23:18:45 -04:00
3c0a81359c Make auto_args default fallback, integrate io_actions with argument parsing 2025-05-19 20:03:04 -04:00
4fa6e3bf1f Merge pull request 'Add auto_args' (#3) from argparse-integration into main
Reviewed-on: #3
2025-05-18 22:27:27 -04:00
113 changed files with 10919 additions and 3122 deletions

154
README.md
View File

@ -10,7 +10,7 @@
- ⚙️ Full lifecycle hooks (before, after, success, error, teardown)
- 📊 Execution tracing, logging, and introspection
- 🧙‍♂️ Async-first design with Process support
- 🧩 Extensible CLI menus and customizable output
- 🧩 Extensible CLI menus, customizable bottom bars, and keyboard shortcuts
> Built for developers who value *clarity*, *resilience*, and *visibility* in their terminal workflows.
@ -21,12 +21,13 @@
Modern CLI tools deserve the same resilience as production systems. Falyx makes it easy to:
- Compose workflows using `Action`, `ChainedAction`, or `ActionGroup`
- Inject the result of one step into the next (`last_result`)
- Handle flaky operations with retries and exponential backoff
- Inject the result of one step into the next (`last_result` / `auto_inject`)
- Handle flaky operations with retries, backoff, and jitter
- Roll back safely on failure with structured undo logic
- Add observability with execution timing, result tracking, and hooks
- Add observability with timing, tracebacks, and lifecycle hooks
- Run in both interactive *and* headless (scriptable) modes
- Customize output with Rich `Table`s (grouping, theming, etc.)
- Support config-driven workflows with YAML or TOML
- Visualize tagged command groups and menu state via Rich tables
---
@ -52,18 +53,20 @@ poetry install
import asyncio
import random
from falyx import Falyx, Action, ChainedAction
from falyx import Falyx
from falyx.action import Action, ChainedAction
# A flaky async step that fails randomly
async def flaky_step():
await asyncio.sleep(0.2)
if random.random() < 0.5:
raise RuntimeError("Random failure!")
print("ok")
return "ok"
# Create the actions
step1 = Action(name="step_1", action=flaky_step, retry=True)
step2 = Action(name="step_2", action=flaky_step, retry=True)
step1 = Action(name="step_1", action=flaky_step)
step2 = Action(name="step_2", action=flaky_step)
# Chain the actions
chain = ChainedAction(name="my_pipeline", actions=[step1, step2])
@ -74,9 +77,11 @@ falyx.add_command(
key="R",
description="Run My Pipeline",
action=chain,
logging_hooks=True,
preview_before_confirm=True,
confirm=True,
retry_all=True,
spinner=True,
style="cyan",
)
# Entry point
@ -85,76 +90,131 @@ if __name__ == "__main__":
```
```bash
python simple.py
$ python simple.py
🚀 Falyx Demo
[R] Run My Pipeline
[Y] History [Q] Exit
[H] Help [Y] History [X] Exit
>
```
```bash
python simple.py run R
$ python simple.py run r
Command: 'R' — Run My Pipeline
└── ⛓ ChainedAction 'my_pipeline'
├── ⚙ Action 'step_1'
│ ↻ Retries: 3x, delay 1.0s, backoff 2.0x
└── ⚙ Action 'step_2'
↻ Retries: 3x, delay 1.0s, backoff 2.0x
Confirm execution of R — Run My Pipeline (calls `my_pipeline`) [Y/n] y
[2025-04-15 22:03:57] WARNING ⚠️ Retry attempt 1/3 failed due to 'Random failure!'.
✅ Result: ['ok', 'ok']
Confirm execution of R — Run My Pipeline (calls `my_pipeline`) [Y/n] > y
[2025-07-20 09:29:35] WARNING Retry attempt 1/3 failed due to 'Random failure!'.
ok
[2025-07-20 09:29:38] WARNING Retry attempt 1/3 failed due to 'Random failure!'.
ok
```
---
## 📦 Core Features
- ✅ Async-native `Action`, `ChainedAction`, `ActionGroup`
- 🔁 Retry policies + exponential backoff
- ⛓ Rollbacks on chained failures
- 🎛️ Headless or interactive CLI with argparse and prompt_toolkit
- 📊 Built-in execution registry, result tracking, and timing
- 🧠 Supports `ProcessAction` for CPU-bound workloads
- 🧩 Custom `Table` rendering for CLI menu views
- 🔍 Hook lifecycle: `before`, `on_success`, `on_error`, `after`, `on_teardown`
- ✅ Async-native `Action`, `ChainedAction`, `ActionGroup`, `ProcessAction`
- 🔁 Retry policies with delay, backoff, jitter — opt-in per action or globally
- ⛓ Rollbacks and lifecycle hooks for chained execution
- 🎛️ Headless or interactive CLI powered by `argparse` + `prompt_toolkit`
- 📊 In-memory `ExecutionRegistry` with result tracking, timing, and tracebacks
- 🌐 CLI menu construction via config files or Python
- ⚡ Bottom bar toggle switches and counters with `Ctrl+<key>` shortcuts
- 🔍 Structured confirmation prompts and help rendering
- 🪵 Flexible logging: Rich console for devs, JSON logs for ops
---
## 🔍 Execution Trace
### 🧰 Building Blocks
```bash
[2025-04-14 10:33:22] DEBUG [Step 1] ⚙ flaky_step()
[2025-04-14 10:33:22] INFO [Step 1] 🔁 Retrying (1/3) in 1.0s...
[2025-04-14 10:33:23] DEBUG [Step 1] ✅ Success | Result: ok
[2025-04-14 10:33:23] DEBUG [My Pipeline] ✅ Result: ['ok', 'ok']
- **`Action`**: A single unit of async (or sync) logic
- **`ChainedAction`**: Execute a sequence of actions, with rollback and injection
- **`ActionGroup`**: Run actions concurrently and collect results
- **`ProcessAction`**: Use `multiprocessing` for CPU-bound workflows
- **`Falyx`**: Interactive or headless CLI controller with history, menus, and theming
- **`ExecutionContext`**: Metadata store per invocation (name, args, result, timing)
- **`HookManager`**: Attach `before`, `after`, `on_success`, `on_error`, `on_teardown`
---
### 🔍 Logging
```
2025-07-20 09:29:32 [falyx] [INFO] Command 'R' selected.
2025-07-20 09:29:32 [falyx] [INFO] [run_key] Executing: R — Run My Pipeline
2025-07-20 09:29:33 [falyx] [INFO] [my_pipeline] Starting -> ChainedAction(name=my_pipeline, actions=['step_1', 'step_2'], args=(), kwargs={}, auto_inject=False, return_list=False)()
2025-07-20 09:29:33 [falyx] [INFO] [step_1] Retrying (1/3) in 1.0s due to 'Random failure!'...
2025-07-20 09:29:35 [falyx] [WARNING] [step_1] Retry attempt 1/3 failed due to 'Random failure!'.
2025-07-20 09:29:35 [falyx] [INFO] [step_1] Retrying (2/3) in 2.0s due to 'Random failure!'...
2025-07-20 09:29:37 [falyx] [INFO] [step_1] Retry succeeded on attempt 2.
2025-07-20 09:29:37 [falyx] [INFO] [step_1] Recovered: step_1
2025-07-20 09:29:37 [falyx] [DEBUG] [step_1] status=OK duration=3.627s result='ok' exception=None
2025-07-20 09:29:37 [falyx] [INFO] [step_2] Retrying (1/3) in 1.0s due to 'Random failure!'...
2025-07-20 09:29:38 [falyx] [WARNING] [step_2] Retry attempt 1/3 failed due to 'Random failure!'.
2025-07-20 09:29:38 [falyx] [INFO] [step_2] Retrying (2/3) in 2.0s due to 'Random failure!'...
2025-07-20 09:29:40 [falyx] [INFO] [step_2] Retry succeeded on attempt 2.
2025-07-20 09:29:40 [falyx] [INFO] [step_2] Recovered: step_2
2025-07-20 09:29:40 [falyx] [DEBUG] [step_2] status=OK duration=3.609s result='ok' exception=None
2025-07-20 09:29:40 [falyx] [DEBUG] [my_pipeline] Success -> Result: 'ok'
2025-07-20 09:29:40 [falyx] [DEBUG] [my_pipeline] Finished in 7.237s
2025-07-20 09:29:40 [falyx] [DEBUG] [my_pipeline] status=OK duration=7.237s result='ok' exception=None
2025-07-20 09:29:40 [falyx] [DEBUG] [Run My Pipeline] status=OK duration=7.238s result='ok' exception=None
```
---
### 📊 History Tracking
### 🧱 Core Building Blocks
View full execution history:
#### `Action`
A single async unit of work. Painless retry support.
```bash
> history
📊 Execution History
#### `ChainedAction`
Run tasks in sequence. Supports rollback on failure and context propagation.
Index Name Start End Duration Status Result / Exception
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
0 step_1 09:23:55 09:23:55 0.201s ✅ Success 'ok'
1 step_2 09:23:55 09:24:03 7.829s ❌ Error RuntimeError('Random failure!')
2 my_pipeline 09:23:55 09:24:03 8.080s ❌ Error RuntimeError('Random failure!')
3 Run My Pipeline 09:23:55 09:24:03 8.082s ❌ Error RuntimeError('Random failure!')
```
#### `ActionGroup`
Run tasks in parallel. Useful for fan-out operations like batch API calls.
Inspect result by index:
#### `ProcessAction`
Offload CPU-bound work to another process — no extra code needed.
```bash
> history --result-index 0
Action(name='step_1', action=flaky_step, args=(), kwargs={}, retry=True, rollback=False) ():
ok
```
#### `Falyx`
Your CLI controller — powers menus, subcommands, history, bottom bars, and more.
Print last result includes tracebacks:
#### `ExecutionContext`
Tracks metadata, arguments, timing, and results for each action execution.
#### `HookManager`
Registers and triggers lifecycle hooks (`before`, `after`, `on_error`, etc.) for actions and commands.
```bash
> history --last-result
Command(key='R', description='Run My Pipeline' action='ChainedAction(name=my_pipeline, actions=['step_1', 'step_2'],
args=(), kwargs={}, auto_inject=False, return_list=False)') ():
Traceback (most recent call last):
File ".../falyx/command.py", line 291, in __call__
result = await self.action(*combined_args, **combined_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File ".../falyx/action/base_action.py", line 91, in __call__
return await self._run(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File ".../falyx/action/chained_action.py", line 212, in _run
result = await prepared(*combined_args, **updated_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File ".../falyx/action/base_action.py", line 91, in __call__
return await self._run(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File ".../falyx/action/action.py", line 157, in _run
result = await self.action(*combined_args, **combined_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File ".../falyx/examples/simple.py", line 15, in flaky_step
raise RuntimeError("Random failure!")
RuntimeError: Random failure!
```
---
@ -162,6 +222,6 @@ Registers and triggers lifecycle hooks (`before`, `after`, `on_error`, etc.) for
> “Like a phalanx: organized, resilient, and reliable.”
Falyx is designed for developers who dont just want CLI tools to run — they want them to **fail meaningfully**, **recover gracefully**, and **log clearly**.
Falyx is designed for developers who dont just want CLI tools to run — they want them to **fail meaningfully**, **recover intentionally**, and **log clearly**.
---

View File

@ -1,6 +1,6 @@
import asyncio
from falyx import Action, ActionGroup, ChainedAction
from falyx.action import Action, ActionGroup, ChainedAction
# Actions can be defined as synchronous functions

View File

@ -1,12 +1,12 @@
import asyncio
from falyx import Falyx
from falyx.action import ActionFactoryAction, ChainedAction, HTTPAction, SelectionAction
from falyx.action import ActionFactory, ChainedAction, HTTPAction, SelectionAction
# Selection of a post ID to fetch (just an example set)
post_selector = SelectionAction(
name="Pick Post ID",
selections=["1", "2", "3", "4", "5"],
selections=["15", "25", "35", "45", "55"],
title="Choose a Post ID to submit",
prompt_message="Post ID > ",
show_table=True,
@ -14,7 +14,7 @@ post_selector = SelectionAction(
# Factory that builds and executes the actual HTTP POST request
def build_post_action(post_id) -> HTTPAction:
async def build_post_action(post_id) -> HTTPAction:
print(f"Building HTTPAction for Post ID: {post_id}")
return HTTPAction(
name=f"POST to /posts (id={post_id})",
@ -24,7 +24,7 @@ def build_post_action(post_id) -> HTTPAction:
)
post_factory = ActionFactoryAction(
post_factory = ActionFactory(
name="Build HTTPAction from Post ID",
factory=build_post_action,
inject_last_result=True,

View File

@ -0,0 +1,101 @@
import asyncio
from enum import Enum
from falyx import Falyx
from falyx.action import Action
from falyx.parser.command_argument_parser import CommandArgumentParser
class Place(Enum):
"""Enum for different places."""
NEW_YORK = "New York"
SAN_FRANCISCO = "San Francisco"
LONDON = "London"
def __str__(self):
return self.value
async def test_args(
service: str,
place: Place = Place.NEW_YORK,
region: str = "us-east-1",
tag: str | None = None,
verbose: bool | None = None,
number: int | None = None,
) -> str:
if verbose:
print(f"Deploying {service}:{tag}:{number} to {region} at {place}...")
return f"{service}:{tag}:{number} deployed to {region} at {place}"
def default_config(parser: CommandArgumentParser) -> None:
"""Default argument configuration for the command."""
parser.add_argument(
"service",
type=str,
choices=["web", "database", "cache"],
help="Service name to deploy.",
)
parser.add_argument(
"place",
type=Place,
choices=list(Place),
default=Place.NEW_YORK,
help="Place where the service will be deployed.",
)
parser.add_argument(
"--region",
type=str,
default="us-east-1",
help="Deployment region.",
choices=["us-east-1", "us-west-2", "eu-west-1"],
)
parser.add_argument(
"--verbose",
action="store_bool_optional",
help="Enable verbose output.",
)
parser.add_argument(
"--tag",
type=str,
help="Optional tag for the deployment.",
suggestions=["latest", "stable", "beta"],
)
parser.add_argument(
"--number",
type=int,
help="Optional number argument.",
)
parser.add_tldr_examples(
[
("web", "Deploy 'web' to the default location (New York)"),
("cache London --tag beta", "Deploy 'cache' to London with tag"),
("database --region us-west-2 --verbose", "Verbose deploy to west region"),
]
)
flx = Falyx(
"Argument Examples",
program="argument_examples.py",
hide_menu_table=True,
show_placeholder_menu=True,
enable_prompt_history=True,
)
flx.add_command(
key="T",
aliases=["test"],
description="Test Command",
help_text="A command to test argument parsing.",
action=Action(
name="test_args",
action=test_args,
),
style="bold #B3EBF2",
argument_config=default_config,
)
asyncio.run(flx.run())

View File

@ -1,6 +1,7 @@
import asyncio
from falyx import Action, ActionGroup, Command, Falyx
from falyx import Falyx
from falyx.action import Action, ActionGroup
# Define a shared async function
@ -19,12 +20,12 @@ action3 = Action("say_hello_3", action=say_hello)
# Combine into an ActionGroup
group = ActionGroup(name="greet_group", actions=[action1, action2, action3])
# Create the Command with auto_args=True
cmd = Command(
flx = Falyx("Test Group")
flx.add_command(
key="G",
description="Greet someone with multiple variations.",
aliases=["greet", "hello"],
action=group,
auto_args=True,
arg_metadata={
"name": {
"help": "The name of the person to greet.",
@ -34,7 +35,4 @@ cmd = Command(
},
},
)
flx = Falyx("Test Group")
flx.add_command_from_command(cmd)
asyncio.run(flx.run())

View File

@ -1,14 +1,19 @@
import asyncio
from falyx import Action, Falyx
from falyx import Falyx
from falyx.action import Action, ChainedAction
from falyx.utils import setup_logging
setup_logging()
async def deploy(service: str, region: str = "us-east-1", verbose: bool = False):
async def deploy(service: str, region: str = "us-east-1", verbose: bool = False) -> str:
if verbose:
print(f"Deploying {service} to {region}...")
await asyncio.sleep(2)
if verbose:
print(f"{service} deployed successfully!")
return f"{service} deployed to {region}"
flx = Falyx("Deployment CLI")
@ -16,17 +21,42 @@ flx = Falyx("Deployment CLI")
flx.add_command(
key="D",
aliases=["deploy"],
description="Deploy a service to a specified region.",
description="Deploy",
help_text="Deploy a service to a specified region.",
action=Action(
name="deploy_service",
action=deploy,
),
auto_args=True,
arg_metadata={
"service": "Service name",
"region": {"help": "Deployment region", "choices": ["us-east-1", "us-west-2"]},
"region": {
"help": "Deployment region",
"choices": ["us-east-1", "us-west-2", "eu-west-1"],
},
"verbose": {"help": "Enable verbose mode"},
},
tags=["deployment", "service"],
)
deploy_chain = ChainedAction(
name="DeployChain",
actions=[
Action(name="deploy_service", action=deploy),
Action(
name="notify",
action=lambda last_result: print(f"Notification: {last_result}"),
),
],
auto_inject=True,
)
flx.add_command(
key="N",
aliases=["notify"],
description="Deploy and Notify",
help_text="Deploy a service and notify.",
action=deploy_chain,
tags=["deployment", "service", "notification"],
)
asyncio.run(flx.run())

127
examples/confirm_example.py Normal file
View File

@ -0,0 +1,127 @@
import asyncio
from typing import Any
from pydantic import BaseModel
from falyx import Falyx
from falyx.action import (
Action,
ActionFactory,
ChainedAction,
ConfirmAction,
SaveFileAction,
)
from falyx.parser import CommandArgumentParser
class Dog(BaseModel):
name: str
age: int
breed: str
async def get_dogs(*dog_names: str) -> list[Dog]:
"""Simulate fetching dog data."""
await asyncio.sleep(0.1) # Simulate network delay
dogs = [
Dog(name="Buddy", age=3, breed="Golden Retriever"),
Dog(name="Max", age=5, breed="Beagle"),
Dog(name="Bella", age=2, breed="Bulldog"),
Dog(name="Charlie", age=4, breed="Poodle"),
Dog(name="Lucy", age=1, breed="Labrador"),
Dog(name="Spot", age=6, breed="German Shepherd"),
]
dogs = [
dog for dog in dogs if dog.name.upper() in (name.upper() for name in dog_names)
]
if not dogs:
raise ValueError(f"No dogs found with the names: {', '.join(dog_names)}")
return dogs
async def build_json_updates(dogs: list[Dog]) -> list[dict[str, Any]]:
"""Build JSON updates for the dogs."""
print(f"Building JSON updates for {','.join(dog.name for dog in dogs)}")
return [dog.model_dump(mode="json") for dog in dogs]
async def save_dogs(dogs) -> None:
if not dogs:
print("No dogs processed.")
return
for result in dogs:
print(f"Saving {Dog(**result)} to file.")
await SaveFileAction(
name="Save Dog Data",
file_path=f"dogs/{result['name']}.json",
data=result,
file_type="json",
)()
async def build_chain(dogs: list[Dog]) -> ChainedAction:
return ChainedAction(
name="test_chain",
actions=[
Action(
name="build_json_updates",
action=build_json_updates,
kwargs={"dogs": dogs},
),
ConfirmAction(
name="test_confirm",
prompt_message="Do you want to process the dogs?",
confirm_type="yes_no_cancel",
return_last_result=True,
inject_into="dogs",
),
Action(
name="save_dogs",
action=save_dogs,
inject_into="dogs",
),
],
auto_inject=True,
)
factory = ActionFactory(
name="Dog Post Factory",
factory=build_chain,
preview_kwargs={"dogs": ["Buddy", "Max"]},
)
def dog_config(parser: CommandArgumentParser) -> None:
parser.add_argument(
"dogs",
nargs="+",
action="action",
resolver=Action("Get Dogs", get_dogs),
lazy_resolver=False,
help="List of dogs to process.",
)
parser.add_tldr_examples(
[
("max", "Process the dog named Max"),
("bella buddy max", "Process the dogs named Bella, Buddy, and Max"),
]
)
async def main():
flx = Falyx("Save Dogs Example", program="confirm_example.py")
flx.add_command(
key="D",
description="Save Dog Data",
action=factory,
aliases=["save_dogs"],
argument_config=dog_config,
)
await flx.run()
if __name__ == "__main__":
asyncio.run(main())

View File

@ -3,7 +3,7 @@ commands:
description: Pipeline Demo
action: pipeline_demo.pipeline
tags: [pipeline, demo]
help_text: Run Demployment Pipeline with retries.
help_text: Run Deployment Pipeline with retries.
- key: G
description: Run HTTP Action Group

View File

@ -7,11 +7,9 @@ Licensed under the MIT License. See LICENSE file for details.
import asyncio
import random
from argparse import Namespace
from falyx.action import Action, ActionGroup, ChainedAction
from falyx.falyx import Falyx
from falyx.parsers import FalyxParsers, get_arg_parsers
from falyx.version import __version__
@ -74,17 +72,10 @@ class Foo:
await self.flx.run()
def parse_args() -> Namespace:
parsers: FalyxParsers = get_arg_parsers()
return parsers.parse_args()
async def main() -> None:
"""Build and return a Falyx instance with all your commands."""
args = parse_args()
flx = Falyx(
title="🚀 Falyx CLI",
cli_args=args,
columns=5,
welcome_message="Welcome to Falyx CLI!",
exit_message="Goodbye!",
@ -93,7 +84,7 @@ async def main() -> None:
# --- Bottom bar info ---
flx.bottom_bar.columns = 3
flx.bottom_bar.add_toggle_from_option("V", "Verbose", flx.options, "verbose")
flx.bottom_bar.add_toggle_from_option("B", "Verbose", flx.options, "verbose")
flx.bottom_bar.add_toggle_from_option("U", "Debug Hooks", flx.options, "debug_hooks")
flx.bottom_bar.add_static("Version", f"Falyx v{__version__}")

View File

@ -2,18 +2,26 @@ import asyncio
from falyx import Falyx
from falyx.action import SelectFileAction
from falyx.action.types import FileReturnType
from falyx.action.action_types import FileType
sf = SelectFileAction(
name="select_file",
suffix_filter=".py",
suffix_filter=".yaml",
title="Select a YAML file",
prompt_message="Choose > ",
return_type=FileReturnType.TEXT,
prompt_message="Choose 2 > ",
return_type=FileType.TEXT,
columns=3,
number_selections=2,
)
flx = Falyx()
flx = Falyx(
title="File Selection Example",
description="This example demonstrates how to select files using Falyx.",
version="1.0.0",
program="file_select.py",
hide_menu_table=True,
show_placeholder_menu=True,
)
flx.add_command(
key="S",

View File

@ -2,9 +2,8 @@ import asyncio
from rich.console import Console
from falyx import ActionGroup, Falyx
from falyx.action import HTTPAction
from falyx.hook_manager import HookType
from falyx import Falyx
from falyx.action import ActionGroup, HTTPAction
from falyx.hooks import ResultReporter
console = Console()
@ -49,7 +48,7 @@ action_group = ActionGroup(
reporter = ResultReporter()
action_group.hooks.register(
HookType.ON_SUCCESS,
"on_success",
reporter.report,
)

View File

@ -2,8 +2,16 @@ import asyncio
import time
from falyx import Falyx
from falyx.action import Action, ActionGroup, ChainedAction, MenuAction, ProcessAction
from falyx.action import (
Action,
ActionGroup,
ChainedAction,
MenuAction,
ProcessAction,
PromptMenuAction,
)
from falyx.menu import MenuOption, MenuOptionMap
from falyx.themes import OneColors
# Basic coroutine for Action
@ -77,20 +85,28 @@ parallel = ActionGroup(
process = ProcessAction(name="compute", action=heavy_computation)
menu_options = MenuOptionMap(
{
"A": MenuOption("Run basic Action", basic_action, style=OneColors.LIGHT_YELLOW),
"C": MenuOption("Run ChainedAction", chained, style=OneColors.MAGENTA),
"P": MenuOption("Run ActionGroup (parallel)", parallel, style=OneColors.CYAN),
"H": MenuOption("Run ProcessAction (heavy task)", process, style=OneColors.GREEN),
}
)
# Menu setup
menu = MenuAction(
name="main-menu",
title="Choose a task to run",
menu_options=MenuOptionMap(
{
"1": MenuOption("Run basic Action", basic_action),
"2": MenuOption("Run ChainedAction", chained),
"3": MenuOption("Run ActionGroup (parallel)", parallel),
"4": MenuOption("Run ProcessAction (heavy task)", process),
}
),
menu_options=menu_options,
)
prompt_menu = PromptMenuAction(
name="select-user",
menu_options=menu_options,
)
flx = Falyx(
@ -108,6 +124,13 @@ flx.add_command(
logging_hooks=True,
)
flx.add_command(
key="P",
description="Show Prompt Menu",
action=prompt_menu,
logging_hooks=True,
)
if __name__ == "__main__":
asyncio.run(flx.run())

View File

@ -1,61 +1,92 @@
import asyncio
import random
import time
from falyx import Action, ActionGroup, ChainedAction
from falyx import ExecutionRegistry as er
from falyx import ProcessAction
from falyx.hook_manager import HookType
from falyx.retry import RetryHandler, RetryPolicy
from falyx import Falyx
from falyx.action import Action, ActionGroup, ChainedAction, ProcessAction
from falyx.console import console
# Step 1: Fast I/O-bound setup (standard Action)
async def checkout_code():
print("📥 Checking out code...")
console.print("🔄 Checking out code...")
await asyncio.sleep(0.5)
console.print("📦 Code checked out successfully.")
# Step 2: CPU-bound task (ProcessAction)
def run_static_analysis():
print("🧠 Running static analysis (CPU-bound)...")
total = 0
for i in range(10_000_000):
total += i % 3
time.sleep(2)
return total
# Step 3: Simulated flaky test with retry
async def flaky_tests():
import random
console.print("🧪 Running tests...")
await asyncio.sleep(0.3)
if random.random() < 0.3:
raise RuntimeError("❌ Random test failure!")
print("🧪 Tests passed.")
console.print("🧪 Tests passed.")
return "ok"
# Step 4: Multiple deploy targets (parallel ActionGroup)
async def deploy_to(target: str):
print(f"🚀 Deploying to {target}...")
await asyncio.sleep(0.2)
console.print(f"🚀 Deploying to {target}...")
await asyncio.sleep(random.randint(2, 6))
console.print(f"✅ Deployment to {target} complete.")
return f"{target} complete"
def build_pipeline():
retry_handler = RetryHandler(RetryPolicy(max_retries=3, delay=0.5))
# Base actions
checkout = Action("Checkout", checkout_code)
analysis = ProcessAction("Static Analysis", run_static_analysis)
tests = Action("Run Tests", flaky_tests)
tests.hooks.register(HookType.ON_ERROR, retry_handler.retry_on_error)
analysis = ProcessAction(
"Static Analysis",
run_static_analysis,
spinner=True,
spinner_message="Analyzing code...",
)
analysis.hooks.register(
"before", lambda ctx: console.print("🧠 Running static analysis (CPU-bound)...")
)
analysis.hooks.register("after", lambda ctx: console.print("🧠 Analysis complete!"))
tests = Action(
"Run Tests",
flaky_tests,
retry=True,
spinner=True,
spinner_message="Running tests...",
)
# Parallel deploys
deploy_group = ActionGroup(
"Deploy to All",
[
Action("Deploy US", deploy_to, args=("us-west",)),
Action("Deploy EU", deploy_to, args=("eu-central",)),
Action("Deploy Asia", deploy_to, args=("asia-east",)),
Action(
"Deploy US",
deploy_to,
args=("us-west",),
spinner=True,
spinner_message="Deploying US...",
),
Action(
"Deploy EU",
deploy_to,
args=("eu-central",),
spinner=True,
spinner_message="Deploying EU...",
),
Action(
"Deploy Asia",
deploy_to,
args=("asia-east",),
spinner=True,
spinner_message="Deploying Asia...",
),
],
)
@ -68,10 +99,22 @@ pipeline = build_pipeline()
# Run the pipeline
async def main():
pipeline = build_pipeline()
await pipeline()
er.summary()
await pipeline.preview()
flx = Falyx(
hide_menu_table=True, program="pipeline_demo.py", show_placeholder_menu=True
)
flx.add_command(
"P",
"Run Pipeline",
pipeline,
spinner=True,
spinner_type="line",
spinner_message="Running pipeline...",
tags=["pipeline", "demo"],
help_text="Run the full CI/CD pipeline demo.",
)
await flx.run()
if __name__ == "__main__":

View File

@ -1,25 +1,36 @@
from rich.console import Console
from falyx import Falyx, ProcessAction
from falyx import Falyx
from falyx.action import ProcessPoolAction
from falyx.action.process_pool_action import ProcessTask
from falyx.execution_registry import ExecutionRegistry as er
from falyx.themes import NordColors as nc
console = Console()
falyx = Falyx(title="🚀 Process Pool Demo")
def generate_primes(n):
primes = []
for num in range(2, n):
def generate_primes(start: int = 2, end: int = 100_000) -> list[int]:
primes: list[int] = []
console.print(f"Generating primes from {start} to {end}...", style=nc.YELLOW)
for num in range(start, end):
if all(num % p != 0 for p in primes):
primes.append(num)
console.print(f"Generated {len(primes)} primes up to {n}.", style=nc.GREEN)
console.print(
f"Generated {len(primes)} primes from {start} to {end}.", style=nc.GREEN
)
return primes
# Will not block the event loop
heavy_action = ProcessAction("Prime Generator", generate_primes, args=(100_000,))
actions = [ProcessTask(task=generate_primes)]
falyx.add_command("R", "Generate Primes", heavy_action, spinner=True)
# Will not block the event loop
heavy_action = ProcessPoolAction(
name="Prime Generator",
actions=actions,
)
falyx.add_command("R", "Generate Primes", heavy_action)
if __name__ == "__main__":

View File

@ -1,6 +1,7 @@
import asyncio
from falyx import Action, Falyx
from falyx import Falyx
from falyx.action import Action
async def main():

View File

@ -1,22 +1,70 @@
import asyncio
from uuid import uuid4
from falyx.selection import (
SelectionOption,
prompt_for_selection,
render_selection_dict_table,
)
from falyx import Falyx
from falyx.action import SelectionAction
from falyx.selection import SelectionOption
from falyx.signals import CancelSignal
menu = {
"A": SelectionOption("Run diagnostics", lambda: print("Running diagnostics...")),
"B": SelectionOption("Deploy to staging", lambda: print("Deploying...")),
selections = {
"1": SelectionOption(
description="Production", value="3bc2616e-3696-11f0-a139-089204eb86ac"
),
"2": SelectionOption(
description="Staging", value="42f2cd84-3696-11f0-a139-089204eb86ac"
),
}
table = render_selection_dict_table(
title="Main Menu",
selections=menu,
select = SelectionAction(
name="Select Deployment",
selections=selections,
title="Select a Deployment",
columns=2,
prompt_message="> ",
return_type="value",
show_table=True,
)
key = asyncio.run(prompt_for_selection(menu.keys(), table))
print(f"You selected: {key}")
list_selections = [uuid4() for _ in range(10)]
menu[key.upper()].value()
list_select = SelectionAction(
name="Select Deployments",
selections=list_selections,
title="Select Deployments",
columns=3,
prompt_message="Select 3 Deployments > ",
return_type="value",
show_table=True,
number_selections=3,
)
flx = Falyx()
flx.add_command(
key="S",
description="Select a deployment",
action=select,
help_text="Select a deployment from the list",
)
flx.add_command(
key="L",
description="Select deployments",
action=list_select,
help_text="Select multiple deployments from the list",
)
if __name__ == "__main__":
try:
print(asyncio.run(select()))
except CancelSignal:
print("Selection was cancelled.")
try:
print(asyncio.run(list_select()))
except CancelSignal:
print("Selection was cancelled.")
asyncio.run(flx.run())

View File

@ -1,9 +1,8 @@
#!/usr/bin/env python
import asyncio
from falyx import Action, ChainedAction, Falyx
from falyx.action import ShellAction
from falyx.hook_manager import HookType
from falyx import Falyx
from falyx.action import Action, ChainedAction, ShellAction
from falyx.hooks import ResultReporter
from falyx.utils import setup_logging
@ -42,12 +41,12 @@ reporter = ResultReporter()
a1 = Action("a1", a1, inject_last_result=True)
a1.hooks.register(
HookType.ON_SUCCESS,
"on_success",
reporter.report,
)
a2 = Action("a2", a2, inject_last_result=True)
a2.hooks.register(
HookType.ON_SUCCESS,
"on_success",
reporter.report,
)

View File

@ -1,7 +1,8 @@
import asyncio
import random
from falyx import Action, ChainedAction, Falyx
from falyx import Falyx
from falyx.action import Action, ChainedAction
from falyx.utils import setup_logging
setup_logging()
@ -10,15 +11,15 @@ setup_logging()
# A flaky async step that fails randomly
async def flaky_step() -> str:
await asyncio.sleep(0.2)
if random.random() < 0.3:
if random.random() < 0.5:
raise RuntimeError("Random failure!")
print("Flaky step succeeded!")
print("ok")
return "ok"
# Create a retry handler
step1 = Action(name="step_1", action=flaky_step, retry=True)
step2 = Action(name="step_2", action=flaky_step, retry=True)
step1 = Action(name="step_1", action=flaky_step)
step2 = Action(name="step_2", action=flaky_step)
# Chain the actions
chain = ChainedAction(name="my_pipeline", actions=[step1, step2])
@ -32,6 +33,8 @@ falyx.add_command(
logging_hooks=True,
preview_before_confirm=True,
confirm=True,
retry_all=True,
spinner=True,
)
# Entry point

View File

@ -1,7 +1,8 @@
import asyncio
import random
from falyx import Action, ChainedAction, Falyx
from falyx import Falyx
from falyx.action import Action, ChainedAction
from falyx.utils import setup_logging
setup_logging()

100
examples/type_validation.py Normal file
View File

@ -0,0 +1,100 @@
import asyncio
from uuid import UUID, uuid4
from falyx import Falyx
from falyx.parser import CommandArgumentParser
flx = Falyx("Test Type Validation")
def uuid_val(value: str) -> str:
"""Custom validator to ensure a string is a valid UUID."""
UUID(value)
return value
async def print_uuid(uuid: str) -> str:
"""Prints the UUID if valid."""
print(f"Valid UUID: {uuid}")
return uuid
flx.add_command(
"U",
"Print a valid UUID (arguemnts)",
print_uuid,
arguments=[
{
"flags": ["uuid"],
"type": uuid_val,
"help": "A valid UUID string",
}
],
)
def uuid_parser(parser: CommandArgumentParser) -> None:
"""Custom parser to ensure the UUID argument is valid."""
parser.add_argument(
"uuid",
type=uuid_val,
help="A valid UUID string",
)
flx.add_command(
"I",
"Print a valid UUID (argument_config)",
print_uuid,
argument_config=uuid_parser,
)
flx.add_command(
"D",
"Print a valid UUID (arg_metadata)",
print_uuid,
arg_metadata={
"uuid": {
"type": uuid_val,
"help": "A valid UUID string",
}
},
)
def custom_parser(arguments: list[str]) -> tuple[tuple, dict]:
"""Custom parser to ensure the UUID argument is valid."""
if len(arguments) != 1:
raise ValueError("Exactly one argument is required")
uuid_val(arguments[0])
return (arguments[0],), {}
flx.add_command(
"C",
"Print a valid UUID (custom_parser)",
print_uuid,
custom_parser=custom_parser,
)
async def generate_uuid() -> str:
"""Generates a new UUID."""
new_uuid = uuid4()
print(f"Generated UUID: {new_uuid}")
return new_uuid
flx.add_command(
"G",
"Generate a new UUID",
lambda: print(uuid4()),
)
async def main() -> None:
await flx.run()
if __name__ == "__main__":
asyncio.run(main())

View File

@ -22,7 +22,7 @@ chain = ChainedAction(
"Name",
UserInputAction(
name="User Input",
prompt_text="Enter your {last_result}: ",
prompt_message="Enter your {last_result}: ",
validator=validate_alpha(),
),
Action(

View File

@ -7,24 +7,13 @@ Licensed under the MIT License. See LICENSE file for details.
import logging
from .action.action import Action, ActionGroup, ChainedAction, ProcessAction
from .command import Command
from .context import ExecutionContext, SharedContext
from .execution_registry import ExecutionRegistry
from .falyx import Falyx
from .hook_manager import HookType
logger = logging.getLogger("falyx")
__all__ = [
"Action",
"ChainedAction",
"ActionGroup",
"ProcessAction",
"Falyx",
"Command",
"ExecutionContext",
"SharedContext",
"ExecutionRegistry",
"HookType",
]

View File

@ -8,13 +8,13 @@ Licensed under the MIT License. See LICENSE file for details.
import asyncio
import os
import sys
from argparse import Namespace
from argparse import ArgumentParser, Namespace, _SubParsersAction
from pathlib import Path
from typing import Any
from falyx.config import loader
from falyx.falyx import Falyx
from falyx.parsers import FalyxParsers, get_arg_parsers
from falyx.parser import CommandArgumentParser, get_root_parser, get_subparsers
def find_falyx_config() -> Path | None:
@ -39,44 +39,81 @@ def bootstrap() -> Path | None:
return config_path
def get_falyx_parsers() -> FalyxParsers:
falyx_parsers: FalyxParsers = get_arg_parsers()
init_parser = falyx_parsers.subparsers.add_parser(
"init", help="Create a new Falyx CLI project"
def init_config(parser: CommandArgumentParser) -> None:
parser.add_argument(
"name",
type=str,
help="Name of the new Falyx project",
default=".",
nargs="?",
)
init_parser.add_argument("name", nargs="?", default=".", help="Project directory")
falyx_parsers.subparsers.add_parser(
"init-global", help="Set up ~/.config/falyx with example tasks"
)
return falyx_parsers
def run(args: Namespace) -> Any:
def init_callback(args: Namespace) -> None:
"""Callback for the init command."""
if args.command == "init":
from falyx.init import init_project
init_project(args.name)
return
if args.command == "init-global":
elif args.command == "init_global":
from falyx.init import init_global
init_global()
return
def get_parsers() -> tuple[ArgumentParser, _SubParsersAction]:
root_parser: ArgumentParser = get_root_parser()
subparsers = get_subparsers(root_parser)
init_parser = subparsers.add_parser(
"init",
help="Initialize a new Falyx project",
description="Create a new Falyx project with mock configuration files.",
epilog="If no name is provided, the current directory will be used.",
)
init_parser.add_argument(
"name",
type=str,
help="Name of the new Falyx project",
default=".",
nargs="?",
)
subparsers.add_parser(
"init-global",
help="Initialize Falyx global configuration",
description="Create a global Falyx configuration at ~/.config/falyx/.",
)
return root_parser, subparsers
def main() -> Any:
bootstrap_path = bootstrap()
if not bootstrap_path:
print("No Falyx config file found. Exiting.")
return None
from falyx.init import init_global, init_project
flx: Falyx = loader(bootstrap_path)
return asyncio.run(flx.run())
flx: Falyx = Falyx()
flx.add_command(
"I",
"Initialize a new Falyx project",
init_project,
aliases=["init"],
argument_config=init_config,
help_epilog="If no name is provided, the current directory will be used.",
)
flx.add_command(
"G",
"Initialize Falyx global configuration",
init_global,
aliases=["init-global"],
help_text="Create a global Falyx configuration at ~/.config/falyx/.",
)
else:
flx = loader(bootstrap_path)
root_parser, subparsers = get_parsers()
def main():
parsers = get_falyx_parsers()
args = parsers.parse_args()
run(args)
return asyncio.run(
flx.run(root_parser=root_parser, subparsers=subparsers, callback=init_callback)
)
if __name__ == "__main__":

View File

@ -5,21 +5,25 @@ Copyright (c) 2025 rtj.dev LLC.
Licensed under the MIT License. See LICENSE file for details.
"""
from .action import (
Action,
ActionGroup,
BaseAction,
ChainedAction,
FallbackAction,
LiteralInputAction,
ProcessAction,
)
from .action_factory import ActionFactoryAction
from .action import Action
from .action_factory import ActionFactory
from .action_group import ActionGroup
from .base_action import BaseAction
from .chained_action import ChainedAction
from .confirm_action import ConfirmAction
from .fallback_action import FallbackAction
from .http_action import HTTPAction
from .io_action import BaseIOAction, ShellAction
from .io_action import BaseIOAction
from .literal_input_action import LiteralInputAction
from .load_file_action import LoadFileAction
from .menu_action import MenuAction
from .process_action import ProcessAction
from .process_pool_action import ProcessPoolAction
from .prompt_menu_action import PromptMenuAction
from .save_file_action import SaveFileAction
from .select_file_action import SelectFileAction
from .selection_action import SelectionAction
from .shell_action import ShellAction
from .signal_action import SignalAction
from .user_input_action import UserInputAction
@ -29,7 +33,7 @@ __all__ = [
"BaseAction",
"ChainedAction",
"ProcessAction",
"ActionFactoryAction",
"ActionFactory",
"HTTPAction",
"BaseIOAction",
"ShellAction",
@ -40,4 +44,9 @@ __all__ = [
"FallbackAction",
"LiteralInputAction",
"UserInputAction",
"PromptMenuAction",
"ProcessPoolAction",
"LoadFileAction",
"SaveFileAction",
"ConfirmAction",
]

View File

@ -1,167 +1,54 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""action.py
"""
Defines `Action`, the core atomic unit in the Falyx CLI framework, used to wrap and
execute a single callable or coroutine with structured lifecycle support.
Core action system for Falyx.
An `Action` is the simplest building block in Falyx's execution model, enabling
developers to turn ordinary Python functions into hookable, retryable, introspectable
workflow steps. It supports synchronous or asynchronous callables, argument injection,
rollback handlers, and retry policies.
This module defines the building blocks for executable actions and workflows,
providing a structured way to compose, execute, recover, and manage sequences of
operations.
Key Features:
- Lifecycle hooks: `before`, `on_success`, `on_error`, `after`, `on_teardown`
- Optional `last_result` injection for chained workflows
- Retry logic via configurable `RetryPolicy` and `RetryHandler`
- Rollback function support for recovery and undo behavior
- Rich preview output for introspection and dry-run diagnostics
All actions are callable and follow a unified signature:
result = action(*args, **kwargs)
Usage Scenarios:
- Wrapping business logic, utility functions, or external API calls
- Converting lightweight callables into structured CLI actions
- Composing workflows using `Action`, `ChainedAction`, or `ActionGroup`
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.
Example:
def compute(x, y):
return x + y
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.
Action(
name="AddNumbers",
action=compute,
args=(2, 3),
)
This design promotes clean, fault-tolerant, modular CLI and automation systems.
This module serves as the foundation for building robust, observable,
and composable CLI automation flows in Falyx.
"""
from __future__ import annotations
import asyncio
import random
from abc import ABC, abstractmethod
from concurrent.futures import ProcessPoolExecutor
from functools import cached_property, partial
from typing import Any, Callable
from typing import Any, Awaitable, Callable
from rich.console import Console
from rich.tree import Tree
from falyx.context import ExecutionContext, SharedContext
from falyx.debug import register_debug_hooks
from falyx.exceptions import EmptyChainError
from falyx.action.base_action import BaseAction
from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import Hook, HookManager, HookType
from falyx.hook_manager import HookManager, HookType
from falyx.logger import logger
from falyx.options_manager import OptionsManager
from falyx.retry import RetryHandler, RetryPolicy
from falyx.themes import OneColors
from falyx.utils import ensure_async
class BaseAction(ABC):
"""
Base class for actions. Actions can be simple functions or more
complex actions like `ChainedAction` or `ActionGroup`. They can also
be run independently or as part of Falyx.
inject_last_result (bool): Whether to inject the previous action's result
into kwargs.
inject_into (str): The name of the kwarg key to inject the result as
(default: 'last_result').
_requires_injection (bool): Whether the action requires input injection.
"""
def __init__(
self,
name: str,
*,
hooks: HookManager | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
never_prompt: bool = False,
logging_hooks: bool = False,
) -> None:
self.name = name
self.hooks = hooks or HookManager()
self.is_retryable: bool = False
self.shared_context: SharedContext | None = None
self.inject_last_result: bool = inject_last_result
self.inject_into: str = inject_into
self._never_prompt: bool = never_prompt
self._requires_injection: bool = False
self._skip_in_chain: bool = False
self.console = Console(color_system="auto")
self.options_manager: OptionsManager | None = None
if logging_hooks:
register_debug_hooks(self.hooks)
async def __call__(self, *args, **kwargs) -> Any:
return await self._run(*args, **kwargs)
@abstractmethod
async def _run(self, *args, **kwargs) -> Any:
raise NotImplementedError("_run must be implemented by subclasses")
@abstractmethod
async def preview(self, parent: Tree | None = None):
raise NotImplementedError("preview must be implemented by subclasses")
def set_options_manager(self, options_manager: OptionsManager) -> None:
self.options_manager = options_manager
def set_shared_context(self, shared_context: SharedContext) -> None:
self.shared_context = shared_context
def get_option(self, option_name: str, default: Any = None) -> Any:
"""
Resolve an option from the OptionsManager if present, otherwise use the fallback.
"""
if self.options_manager:
return self.options_manager.get(option_name, default)
return default
@property
def last_result(self) -> Any:
"""Return the last result from the shared context."""
if self.shared_context:
return self.shared_context.last_result()
return None
@property
def never_prompt(self) -> bool:
return self.get_option("never_prompt", self._never_prompt)
def prepare(
self, shared_context: SharedContext, options_manager: OptionsManager | None = None
) -> BaseAction:
"""
Prepare the action specifically for sequential (ChainedAction) execution.
Can be overridden for chain-specific logic.
"""
self.set_shared_context(shared_context)
if options_manager:
self.set_options_manager(options_manager)
return self
def _maybe_inject_last_result(self, kwargs: dict[str, Any]) -> dict[str, Any]:
if self.inject_last_result and self.shared_context:
key = self.inject_into
if key in kwargs:
logger.warning("[%s] ⚠️ Overriding '%s' with last_result", self.name, key)
kwargs = dict(kwargs)
kwargs[key] = self.shared_context.last_result()
return kwargs
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
"""Register a hook for all actions and sub-actions."""
self.hooks.register(hook_type, hook)
async def _write_stdout(self, data: str) -> None:
"""Override in subclasses that produce terminal output."""
def requires_io_injection(self) -> bool:
"""Checks to see if the action requires input injection."""
return self._requires_injection
def __repr__(self) -> str:
return str(self)
class Action(BaseAction):
"""
Action wraps a simple function or coroutine into a standard executable unit.
@ -173,11 +60,11 @@ class Action(BaseAction):
- Optional rollback handlers for undo logic.
Args:
name (str): Name of the action.
name (str): Name of the action. Used for logging and debugging.
action (Callable): The function or coroutine to execute.
rollback (Callable, optional): Rollback function to undo the action.
args (tuple, optional): Static positional arguments.
kwargs (dict, optional): Static keyword arguments.
args (tuple, optional): Positional arguments.
kwargs (dict, optional): Keyword arguments.
hooks (HookManager, optional): Hook manager for lifecycle events.
inject_last_result (bool, optional): Enable last_result injection.
inject_into (str, optional): Name of injected key.
@ -188,22 +75,36 @@ class Action(BaseAction):
def __init__(
self,
name: str,
action: Callable[..., Any],
action: Callable[..., Any] | Callable[..., Awaitable[Any]],
*,
rollback: Callable[..., Any] | None = None,
rollback: Callable[..., Any] | Callable[..., Awaitable[Any]] | None = None,
args: tuple[Any, ...] = (),
kwargs: dict[str, Any] | None = None,
hooks: HookManager | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
never_prompt: bool | None = None,
logging_hooks: bool = False,
retry: bool = False,
retry_policy: RetryPolicy | None = None,
spinner: bool = False,
spinner_message: str = "Processing...",
spinner_type: str = "dots",
spinner_style: str = OneColors.CYAN,
spinner_speed: float = 1.0,
) -> None:
super().__init__(
name,
hooks=hooks,
inject_last_result=inject_last_result,
inject_into=inject_into,
never_prompt=never_prompt,
logging_hooks=logging_hooks,
spinner=spinner,
spinner_message=spinner_message,
spinner_type=spinner_type,
spinner_style=spinner_style,
spinner_speed=spinner_speed,
)
self.action = action
self.rollback = rollback
@ -215,19 +116,19 @@ class Action(BaseAction):
self.enable_retry()
@property
def action(self) -> Callable[..., Any]:
def action(self) -> Callable[..., Awaitable[Any]]:
return self._action
@action.setter
def action(self, value: Callable[..., Any]):
def action(self, value: Callable[..., Awaitable[Any]]):
self._action = ensure_async(value)
@property
def rollback(self) -> Callable[..., Any] | None:
def rollback(self) -> Callable[..., Awaitable[Any]] | None:
return self._rollback
@rollback.setter
def rollback(self, value: Callable[..., Any] | None):
def rollback(self, value: Callable[..., Awaitable[Any]] | None):
if value is None:
self._rollback = None
else:
@ -246,6 +147,13 @@ class Action(BaseAction):
if policy.enabled:
self.enable_retry()
def get_infer_target(self) -> tuple[Callable[..., Any], None]:
"""
Returns the callable to be used for argument inference.
By default, it returns the action itself.
"""
return self.action, None
async def _run(self, *args, **kwargs) -> Any:
combined_args = args + self.args
combined_kwargs = self._maybe_inject_last_result({**self.kwargs, **kwargs})
@ -268,7 +176,7 @@ class Action(BaseAction):
context.exception = error
await self.hooks.trigger(HookType.ON_ERROR, context)
if context.result is not None:
logger.info("[%s] Recovered: %s", self.name, self.name)
logger.info("[%s] Recovered: %s", self.name, self.name)
return context.result
raise
finally:
@ -297,558 +205,6 @@ class Action(BaseAction):
f"Action(name={self.name!r}, action="
f"{getattr(self._action, '__name__', repr(self._action))}, "
f"args={self.args!r}, kwargs={self.kwargs!r}, "
f"retry={self.retry_policy.enabled})"
)
class LiteralInputAction(Action):
"""
LiteralInputAction injects a static value into a ChainedAction.
This allows embedding hardcoded values mid-pipeline, useful when:
- Providing default or fallback inputs.
- Starting a pipeline with a fixed input.
- Supplying missing context manually.
Args:
value (Any): The static value to inject.
"""
def __init__(self, value: Any):
self._value = value
async def literal(*_, **__):
return value
super().__init__("Input", literal)
@cached_property
def value(self) -> Any:
"""Return the literal value."""
return self._value
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.LIGHT_YELLOW}]📥 LiteralInput[/] '{self.name}'"]
label.append(f" [dim](value = {repr(self.value)})[/dim]")
if parent:
parent.add("".join(label))
else:
self.console.print(Tree("".join(label)))
def __str__(self) -> str:
return f"LiteralInputAction(value={self.value!r})"
class FallbackAction(Action):
"""
FallbackAction provides a default value if the previous action failed or
returned None.
It injects the last result and checks:
- If last_result is not None, it passes it through unchanged.
- If last_result is None (e.g., due to failure), it replaces it with a fallback value.
Used in ChainedAction pipelines to gracefully recover from errors or missing data.
When activated, it consumes the preceding error and allows the chain to continue
normally.
Args:
fallback (Any): The fallback value to use if last_result is None.
"""
def __init__(self, fallback: Any):
self._fallback = fallback
async def _fallback_logic(last_result):
return last_result if last_result is not None else fallback
super().__init__(name="Fallback", action=_fallback_logic, inject_last_result=True)
@cached_property
def fallback(self) -> Any:
"""Return the fallback value."""
return self._fallback
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.LIGHT_RED}]🛟 Fallback[/] '{self.name}'"]
label.append(f" [dim](uses fallback = {repr(self.fallback)})[/dim]")
if parent:
parent.add("".join(label))
else:
self.console.print(Tree("".join(label)))
def __str__(self) -> str:
return f"FallbackAction(fallback={self.fallback!r})"
class ActionListMixin:
"""Mixin for managing a list of actions."""
def __init__(self) -> None:
self.actions: list[BaseAction] = []
def set_actions(self, actions: list[BaseAction]) -> None:
"""Replaces the current action list with a new one."""
self.actions.clear()
for action in actions:
self.add_action(action)
def add_action(self, action: BaseAction) -> None:
"""Adds an action to the list."""
self.actions.append(action)
def remove_action(self, name: str) -> None:
"""Removes an action by name."""
self.actions = [action for action in self.actions if action.name != name]
def has_action(self, name: str) -> bool:
"""Checks if an action with the given name exists."""
return any(action.name == name for action in self.actions)
def get_action(self, name: str) -> BaseAction | None:
"""Retrieves an action by name."""
for action in self.actions:
if action.name == name:
return action
return None
class ChainedAction(BaseAction, ActionListMixin):
"""
ChainedAction executes a sequence of actions one after another.
Features:
- Supports optional automatic last_result injection (auto_inject).
- Recovers from intermediate errors using FallbackAction if present.
- Rolls back all previously executed actions if a failure occurs.
- Handles literal values with LiteralInputAction.
Best used for defining robust, ordered workflows where each step can depend on
previous results.
Args:
name (str): Name of the chain.
actions (list): List of actions or literals to execute.
hooks (HookManager, optional): Hooks for lifecycle events.
inject_last_result (bool, optional): Whether to inject last results into kwargs
by default.
inject_into (str, optional): Key name for injection.
auto_inject (bool, optional): Auto-enable injection for subsequent actions.
return_list (bool, optional): Whether to return a list of all results. False
returns the last result.
"""
def __init__(
self,
name: str,
actions: list[BaseAction | Any] | None = None,
*,
hooks: HookManager | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
auto_inject: bool = False,
return_list: bool = False,
) -> None:
super().__init__(
name,
hooks=hooks,
inject_last_result=inject_last_result,
inject_into=inject_into,
)
ActionListMixin.__init__(self)
self.auto_inject = auto_inject
self.return_list = return_list
if actions:
self.set_actions(actions)
def _wrap_if_needed(self, action: BaseAction | Any) -> BaseAction:
if isinstance(action, BaseAction):
return action
elif callable(action):
return Action(name=action.__name__, action=action)
else:
return LiteralInputAction(action)
def add_action(self, action: BaseAction | Any) -> None:
action = self._wrap_if_needed(action)
if self.actions and self.auto_inject and not action.inject_last_result:
action.inject_last_result = True
super().add_action(action)
if hasattr(action, "register_teardown") and callable(action.register_teardown):
action.register_teardown(self.hooks)
async def _run(self, *args, **kwargs) -> list[Any]:
if not self.actions:
raise EmptyChainError(f"[{self.name}] No actions to execute.")
shared_context = SharedContext(name=self.name, action=self)
if self.shared_context:
shared_context.add_result(self.shared_context.last_result())
updated_kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext(
name=self.name,
args=args,
kwargs=updated_kwargs,
action=self,
extra={"results": [], "rollback_stack": []},
shared_context=shared_context,
)
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
for index, action in enumerate(self.actions):
if action._skip_in_chain:
logger.debug(
"[%s] ⚠️ Skipping consumed action '%s'", self.name, action.name
)
continue
shared_context.current_index = index
prepared = action.prepare(shared_context, self.options_manager)
last_result = shared_context.last_result()
try:
if self.requires_io_injection() and last_result is not None:
result = await prepared(**{prepared.inject_into: last_result})
else:
result = await prepared(*args, **updated_kwargs)
except Exception as error:
if index + 1 < len(self.actions) and isinstance(
self.actions[index + 1], FallbackAction
):
logger.warning(
"[%s] ⚠️ Fallback triggered: %s, recovering with fallback "
"'%s'.",
self.name,
error,
self.actions[index + 1].name,
)
shared_context.add_result(None)
context.extra["results"].append(None)
fallback = self.actions[index + 1].prepare(shared_context)
result = await fallback()
fallback._skip_in_chain = True
else:
raise
shared_context.add_result(result)
context.extra["results"].append(result)
context.extra["rollback_stack"].append(prepared)
all_results = context.extra["results"]
assert (
all_results
), f"[{self.name}] No results captured. Something seriously went wrong."
context.result = all_results if self.return_list else all_results[-1]
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return context.result
except Exception as error:
context.exception = error
shared_context.add_error(shared_context.current_index, error)
await self._rollback(context.extra["rollback_stack"], *args, **kwargs)
await self.hooks.trigger(HookType.ON_ERROR, context)
raise
finally:
context.stop_timer()
await self.hooks.trigger(HookType.AFTER, context)
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
er.record(context)
async def _rollback(self, rollback_stack, *args, **kwargs):
"""
Roll back all executed actions in reverse order.
Rollbacks run even if a fallback recovered from failure,
ensuring consistent undo of all side effects.
Actions without rollback handlers are skipped.
Args:
rollback_stack (list): Actions to roll back.
*args, **kwargs: Passed to rollback handlers.
"""
for action in reversed(rollback_stack):
rollback = getattr(action, "rollback", None)
if rollback:
try:
logger.warning("[%s] ↩️ Rolling back...", action.name)
await action.rollback(*args, **kwargs)
except Exception as error:
logger.error("[%s] ⚠️ Rollback failed: %s", action.name, error)
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
"""Register a hook for all actions and sub-actions."""
self.hooks.register(hook_type, hook)
for action in self.actions:
action.register_hooks_recursively(hook_type, hook)
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.CYAN_b}]⛓ ChainedAction[/] '{self.name}'"]
if self.inject_last_result:
label.append(f" [dim](injects '{self.inject_into}')[/dim]")
tree = parent.add("".join(label)) if parent else Tree("".join(label))
for action in self.actions:
await action.preview(parent=tree)
if not parent:
self.console.print(tree)
def __str__(self):
return (
f"ChainedAction(name={self.name!r}, "
f"actions={[a.name for a in self.actions]!r}, "
f"auto_inject={self.auto_inject}, return_list={self.return_list})"
)
class ActionGroup(BaseAction, ActionListMixin):
"""
ActionGroup executes multiple actions concurrently in parallel.
It is ideal for independent tasks that can be safely run simultaneously,
improving overall throughput and responsiveness of workflows.
Core features:
- Parallel execution of all contained actions.
- Shared last_result injection across all actions if configured.
- Aggregated collection of individual results as (name, result) pairs.
- Hook lifecycle support (before, on_success, on_error, after, on_teardown).
- Error aggregation: captures all action errors and reports them together.
Behavior:
- If any action fails, the group collects the errors but continues executing
other actions without interruption.
- After all actions complete, ActionGroup raises a single exception summarizing
all failures, or returns all results if successful.
Best used for:
- Batch processing multiple independent tasks.
- Reducing latency for workflows with parallelizable steps.
- Isolating errors while maximizing successful execution.
Args:
name (str): Name of the chain.
actions (list): List of actions or literals to execute.
hooks (HookManager, optional): Hooks for lifecycle events.
inject_last_result (bool, optional): Whether to inject last results into kwargs
by default.
inject_into (str, optional): Key name for injection.
"""
def __init__(
self,
name: str,
actions: list[BaseAction] | None = None,
*,
hooks: HookManager | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
):
super().__init__(
name,
hooks=hooks,
inject_last_result=inject_last_result,
inject_into=inject_into,
)
ActionListMixin.__init__(self)
if actions:
self.set_actions(actions)
def _wrap_if_needed(self, action: BaseAction | Any) -> BaseAction:
if isinstance(action, BaseAction):
return action
elif callable(action):
return Action(name=action.__name__, action=action)
else:
raise TypeError(
"ActionGroup only accepts BaseAction or callable, got "
f"{type(action).__name__}"
)
def add_action(self, action: BaseAction | Any) -> None:
action = self._wrap_if_needed(action)
super().add_action(action)
if hasattr(action, "register_teardown") and callable(action.register_teardown):
action.register_teardown(self.hooks)
async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]:
shared_context = SharedContext(name=self.name, action=self, is_parallel=True)
if self.shared_context:
shared_context.set_shared_result(self.shared_context.last_result())
updated_kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext(
name=self.name,
args=args,
kwargs=updated_kwargs,
action=self,
extra={"results": [], "errors": []},
shared_context=shared_context,
)
async def run_one(action: BaseAction):
try:
prepared = action.prepare(shared_context, self.options_manager)
result = await prepared(*args, **updated_kwargs)
shared_context.add_result((action.name, result))
context.extra["results"].append((action.name, result))
except Exception as error:
shared_context.add_error(shared_context.current_index, error)
context.extra["errors"].append((action.name, error))
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
await asyncio.gather(*[run_one(a) for a in self.actions])
if context.extra["errors"]:
context.exception = Exception(
f"{len(context.extra['errors'])} action(s) failed: "
f"{' ,'.join(name for name, _ in context.extra["errors"])}"
)
await self.hooks.trigger(HookType.ON_ERROR, context)
raise context.exception
context.result = context.extra["results"]
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return context.result
except Exception as error:
context.exception = error
raise
finally:
context.stop_timer()
await self.hooks.trigger(HookType.AFTER, context)
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
er.record(context)
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
"""Register a hook for all actions and sub-actions."""
super().register_hooks_recursively(hook_type, hook)
for action in self.actions:
action.register_hooks_recursively(hook_type, hook)
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.MAGENTA_b}]⏩ ActionGroup (parallel)[/] '{self.name}'"]
if self.inject_last_result:
label.append(f" [dim](receives '{self.inject_into}')[/dim]")
tree = parent.add("".join(label)) if parent else Tree("".join(label))
actions = self.actions.copy()
random.shuffle(actions)
await asyncio.gather(*(action.preview(parent=tree) for action in actions))
if not parent:
self.console.print(tree)
def __str__(self):
return (
f"ActionGroup(name={self.name!r}, actions={[a.name for a in self.actions]!r},"
f" inject_last_result={self.inject_last_result})"
)
class ProcessAction(BaseAction):
"""
ProcessAction runs a function in a separate process using ProcessPoolExecutor.
Features:
- Executes CPU-bound or blocking tasks without blocking the main event loop.
- Supports last_result injection into the subprocess.
- Validates that last_result is pickleable when injection is enabled.
Args:
name (str): Name of the action.
func (Callable): Function to execute in a new process.
args (tuple, optional): Positional arguments.
kwargs (dict, optional): Keyword arguments.
hooks (HookManager, optional): Hook manager for lifecycle events.
executor (ProcessPoolExecutor, optional): Custom executor if desired.
inject_last_result (bool, optional): Inject last result into the function.
inject_into (str, optional): Name of the injected key.
"""
def __init__(
self,
name: str,
action: Callable[..., Any],
*,
args: tuple = (),
kwargs: dict[str, Any] | None = None,
hooks: HookManager | None = None,
executor: ProcessPoolExecutor | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
):
super().__init__(
name,
hooks=hooks,
inject_last_result=inject_last_result,
inject_into=inject_into,
)
self.action = action
self.args = args
self.kwargs = kwargs or {}
self.executor = executor or ProcessPoolExecutor()
self.is_retryable = True
async def _run(self, *args, **kwargs):
if self.inject_last_result:
last_result = self.shared_context.last_result()
if not self._validate_pickleable(last_result):
raise ValueError(
f"Cannot inject last result into {self.name}: "
f"last result is not pickleable."
)
combined_args = args + self.args
combined_kwargs = self._maybe_inject_last_result({**self.kwargs, **kwargs})
context = ExecutionContext(
name=self.name,
args=combined_args,
kwargs=combined_kwargs,
action=self,
)
loop = asyncio.get_running_loop()
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
result = await loop.run_in_executor(
self.executor, partial(self.action, *combined_args, **combined_kwargs)
)
context.result = result
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return result
except Exception as error:
context.exception = error
await self.hooks.trigger(HookType.ON_ERROR, context)
if context.result is not None:
return context.result
raise
finally:
context.stop_timer()
await self.hooks.trigger(HookType.AFTER, context)
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
er.record(context)
def _validate_pickleable(self, obj: Any) -> bool:
try:
import pickle
pickle.dumps(obj)
return True
except (pickle.PicklingError, TypeError):
return False
async def preview(self, parent: Tree | None = None):
label = [
f"[{OneColors.DARK_YELLOW_b}]🧠 ProcessAction (new process)[/] '{self.name}'"
]
if self.inject_last_result:
label.append(f" [dim](injects '{self.inject_into}')[/dim]")
if parent:
parent.add("".join(label))
else:
self.console.print(Tree("".join(label)))
def __str__(self) -> str:
return (
f"ProcessAction(name={self.name!r}, "
f"action={getattr(self.action, '__name__', repr(self.action))}, "
f"args={self.args!r}, kwargs={self.kwargs!r})"
f"retry={self.retry_policy.enabled}, "
f"rollback={self.rollback is not None})"
)

View File

@ -1,10 +1,41 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""action_factory.py"""
from typing import Any
"""
Defines `ActionFactory`, a dynamic Falyx Action that defers the construction of its
underlying logic to runtime using a user-defined factory function.
This pattern is useful when the specific Action to execute cannot be determined until
execution time—such as when branching on data, generating parameterized HTTP requests,
or selecting configuration-aware flows. `ActionFactory` integrates seamlessly with the
Falyx lifecycle system and supports hook propagation, teardown registration, and
contextual previewing.
Key Features:
- Accepts a factory function that returns a `BaseAction` instance
- Supports injection of `last_result` and arbitrary args/kwargs
- Integrates into chained or standalone workflows
- Automatically previews generated action tree
- Propagates shared context and teardown hooks to the returned action
Common Use Cases:
- Conditional or data-driven action generation
- Configurable workflows with dynamic behavior
- Adapter for factory-style dependency injection in CLI flows
Example:
def generate_request_action(env):
return HTTPAction(f"GET /status/{env}", url=f"https://api/{env}/status")
ActionFactory(
name="GetEnvStatus",
factory=generate_request_action,
inject_last_result=True,
)
"""
from typing import Any, Callable
from rich.tree import Tree
from falyx.action.action import BaseAction
from falyx.action.base_action import BaseAction
from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType
@ -14,7 +45,7 @@ from falyx.themes import OneColors
from falyx.utils import ensure_async
class ActionFactoryAction(BaseAction):
class ActionFactory(BaseAction):
"""
Dynamically creates and runs another Action at runtime using a factory function.
@ -22,10 +53,14 @@ class ActionFactoryAction(BaseAction):
where the structure of the next action depends on runtime values.
Args:
name (str): Name of the action.
name (str): Name of the action. Used for logging and debugging.
factory (Callable): A function that returns a BaseAction given args/kwargs.
inject_last_result (bool): Whether to inject last_result into the factory.
inject_into (str): The name of the kwarg to inject last_result as.
args (tuple, optional): Positional arguments for the factory.
kwargs (dict, optional): Keyword arguments for the factory.
preview_args (tuple, optional): Positional arguments for the preview.
preview_kwargs (dict, optional): Keyword arguments for the preview.
"""
def __init__(
@ -35,6 +70,8 @@ class ActionFactoryAction(BaseAction):
*,
inject_last_result: bool = False,
inject_into: str = "last_result",
args: tuple[Any, ...] = (),
kwargs: dict[str, Any] | None = None,
preview_args: tuple[Any, ...] = (),
preview_kwargs: dict[str, Any] | None = None,
):
@ -44,6 +81,8 @@ class ActionFactoryAction(BaseAction):
inject_into=inject_into,
)
self.factory = factory
self.args = args
self.kwargs = kwargs or {}
self.preview_args = preview_args
self.preview_kwargs = preview_kwargs or {}
@ -55,7 +94,12 @@ class ActionFactoryAction(BaseAction):
def factory(self, value: ActionFactoryProtocol):
self._factory = ensure_async(value)
def get_infer_target(self) -> tuple[Callable[..., Any], None]:
return self.factory, None
async def _run(self, *args, **kwargs) -> Any:
args = (*self.args, *args)
kwargs = {**self.kwargs, **kwargs}
updated_kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext(
name=f"{self.name} (factory)",
@ -85,7 +129,7 @@ class ActionFactoryAction(BaseAction):
)
if self.options_manager:
generated_action.set_options_manager(self.options_manager)
context.result = await generated_action(*args, **kwargs)
context.result = await generated_action()
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return context.result
except Exception as error:
@ -103,7 +147,16 @@ class ActionFactoryAction(BaseAction):
tree = parent.add(label) if parent else Tree(label)
try:
generated = None
if self.args or self.kwargs:
try:
generated = await self.factory(*self.args, **self.kwargs)
except TypeError:
...
if not generated:
generated = await self.factory(*self.preview_args, **self.preview_kwargs)
if isinstance(generated, BaseAction):
await generated.preview(parent=tree)
else:
@ -115,3 +168,11 @@ class ActionFactoryAction(BaseAction):
if not parent:
self.console.print(tree)
def __str__(self) -> str:
return (
f"ActionFactory(name={self.name!r}, "
f"inject_last_result={self.inject_last_result}, "
f"factory={self._factory.__name__ if hasattr(self._factory, '__name__') else type(self._factory).__name__}, "
f"args={self.args!r}, kwargs={self.kwargs!r})"
)

View File

@ -0,0 +1,248 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `ActionGroup`, a Falyx Action that executes multiple sub-actions concurrently
using asynchronous parallelism.
`ActionGroup` is designed for workflows where several independent actions can run
simultaneously to improve responsiveness and reduce latency. It ensures robust error
isolation, shared result tracking, and full lifecycle hook integration while preserving
Falyx's introspectability and chaining capabilities.
Key Features:
- Executes all actions in parallel via `asyncio.gather`
- Aggregates results as a list of `(name, result)` tuples
- Collects and reports multiple errors without interrupting execution
- Compatible with `SharedContext`, `OptionsManager`, and `last_result` injection
- Teardown-aware: propagates teardown registration across all child actions
- Fully previewable via Rich tree rendering
Use Cases:
- Batch execution of independent tasks (e.g., multiple file operations, API calls)
- Concurrent report generation or validations
- High-throughput CLI pipelines where latency is critical
Raises:
- `EmptyGroupError`: If no actions are added to the group
- `Exception`: Summarizes all failed actions after execution
Example:
ActionGroup(
name="ParallelChecks",
actions=[Action(...), Action(...), ChainedAction(...)],
)
This module complements `ChainedAction` by offering breadth-wise (parallel) execution
as opposed to depth-wise (sequential) execution.
"""
import asyncio
import random
from typing import Any, Awaitable, Callable, Sequence
from rich.tree import Tree
from falyx.action.action import Action
from falyx.action.action_mixins import ActionListMixin
from falyx.action.base_action import BaseAction
from falyx.context import ExecutionContext, SharedContext
from falyx.exceptions import EmptyGroupError
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import Hook, HookManager, HookType
from falyx.logger import logger
from falyx.options_manager import OptionsManager
from falyx.parser.utils import same_argument_definitions
from falyx.themes.colors import OneColors
class ActionGroup(BaseAction, ActionListMixin):
"""
ActionGroup executes multiple actions concurrently in parallel.
It is ideal for independent tasks that can be safely run simultaneously,
improving overall throughput and responsiveness of workflows.
Core features:
- Parallel execution of all contained actions.
- Shared last_result injection across all actions if configured.
- Aggregated collection of individual results as (name, result) pairs.
- Hook lifecycle support (before, on_success, on_error, after, on_teardown).
- Error aggregation: captures all action errors and reports them together.
Behavior:
- If any action fails, the group collects the errors but continues executing
other actions without interruption.
- After all actions complete, ActionGroup raises a single exception summarizing
all failures, or returns all results if successful.
Best used for:
- Batch processing multiple independent tasks.
- Reducing latency for workflows with parallelizable steps.
- Isolating errors while maximizing successful execution.
Args:
name (str): Name of the chain.
actions (list): List of actions or literals to execute.
args (tuple, optional): Positional arguments.
kwargs (dict, optional): Keyword arguments.
hooks (HookManager, optional): Hooks for lifecycle events.
inject_last_result (bool, optional): Whether to inject last results into kwargs
by default.
inject_into (str, optional): Key name for injection.
"""
def __init__(
self,
name: str,
actions: (
Sequence[BaseAction | Callable[..., Any] | Callable[..., Awaitable]] | None
) = None,
*,
args: tuple[Any, ...] = (),
kwargs: dict[str, Any] | None = None,
hooks: HookManager | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
never_prompt: bool | None = None,
logging_hooks: bool = False,
spinner: bool = False,
spinner_message: str = "Processing...",
spinner_type: str = "dots",
spinner_style: str = OneColors.CYAN,
spinner_speed: float = 1.0,
):
super().__init__(
name,
hooks=hooks,
inject_last_result=inject_last_result,
inject_into=inject_into,
never_prompt=never_prompt,
logging_hooks=logging_hooks,
spinner=spinner,
spinner_message=spinner_message,
spinner_type=spinner_type,
spinner_style=spinner_style,
spinner_speed=spinner_speed,
)
ActionListMixin.__init__(self)
self.args = args
self.kwargs = kwargs or {}
if actions:
self.set_actions(actions)
def _wrap_if_needed(self, action: BaseAction | Callable[..., Any]) -> BaseAction:
if isinstance(action, BaseAction):
return action
elif callable(action):
return Action(name=action.__name__, action=action)
else:
raise TypeError(
"ActionGroup only accepts BaseAction or callable, got "
f"{type(action).__name__}"
)
def add_action(self, action: BaseAction | Callable[..., Any]) -> None:
action = self._wrap_if_needed(action)
super().add_action(action)
if hasattr(action, "register_teardown") and callable(action.register_teardown):
action.register_teardown(self.hooks)
def set_actions(self, actions: Sequence[BaseAction | Callable[..., Any]]) -> None:
"""Replaces the current action list with a new one."""
self.actions.clear()
for action in actions:
self.add_action(action)
def set_options_manager(self, options_manager: OptionsManager) -> None:
super().set_options_manager(options_manager)
for action in self.actions:
action.set_options_manager(options_manager)
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
arg_defs = same_argument_definitions(self.actions)
if arg_defs:
return self.actions[0].get_infer_target()
logger.debug(
"[%s] auto_args disabled: mismatched ActionGroup arguments",
self.name,
)
return None, None
async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]:
if not self.actions:
raise EmptyGroupError(f"[{self.name}] No actions to execute.")
combined_args = args + self.args
combined_kwargs = {**self.kwargs, **kwargs}
shared_context = SharedContext(name=self.name, action=self, is_parallel=True)
if self.shared_context:
shared_context.set_shared_result(self.shared_context.last_result())
updated_kwargs = self._maybe_inject_last_result(combined_kwargs)
context = ExecutionContext(
name=self.name,
args=combined_args,
kwargs=updated_kwargs,
action=self,
extra={"results": [], "errors": []},
shared_context=shared_context,
)
async def run_one(action: BaseAction):
try:
prepared = action.prepare(shared_context, self.options_manager)
result = await prepared(*combined_args, **updated_kwargs)
shared_context.add_result((action.name, result))
context.extra["results"].append((action.name, result))
except Exception as error:
shared_context.add_error(shared_context.current_index, error)
context.extra["errors"].append((action.name, error))
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
await asyncio.gather(*[run_one(a) for a in self.actions])
if context.extra["errors"]:
context.exception = Exception(
f"{len(context.extra['errors'])} action(s) failed: "
f"{' ,'.join(name for name, _ in context.extra['errors'])}"
)
await self.hooks.trigger(HookType.ON_ERROR, context)
raise context.exception
context.result = context.extra["results"]
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return context.result
except Exception as error:
context.exception = error
raise
finally:
context.stop_timer()
await self.hooks.trigger(HookType.AFTER, context)
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
er.record(context)
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
"""Register a hook for all actions and sub-actions."""
super().register_hooks_recursively(hook_type, hook)
for action in self.actions:
action.register_hooks_recursively(hook_type, hook)
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.MAGENTA_b}]⏩ ActionGroup (parallel)[/] '{self.name}'"]
if self.inject_last_result:
label.append(f" [dim](receives '{self.inject_into}')[/dim]")
tree = parent.add("".join(label)) if parent else Tree("".join(label))
actions = self.actions.copy()
random.shuffle(actions)
await asyncio.gather(*(action.preview(parent=tree) for action in actions))
if not parent:
self.console.print(tree)
def __str__(self):
return (
f"ActionGroup(name={self.name}, actions={[a.name for a in self.actions]}, "
f"args={self.args!r}, kwargs={self.kwargs!r}, "
f"inject_last_result={self.inject_last_result}, "
f"inject_into={self.inject_into})"
)

View File

@ -0,0 +1,60 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Provides reusable mixins for managing collections of `BaseAction` instances
within composite Falyx actions such as `ActionGroup` or `ChainedAction`.
The primary export, `ActionListMixin`, encapsulates common functionality for
maintaining a mutable list of named actions—such as adding, removing, or retrieving
actions by name—without duplicating logic across composite action types.
"""
from typing import Sequence
from falyx.action.base_action import BaseAction
class ActionListMixin:
"""
Mixin for managing a list of named `BaseAction` objects.
Provides helper methods for setting, adding, removing, checking, and
retrieving actions in composite Falyx constructs like `ActionGroup`.
Attributes:
actions (list[BaseAction]): The internal list of managed actions.
Methods:
set_actions(actions): Replaces all current actions with the given list.
add_action(action): Adds a new action to the list.
remove_action(name): Removes an action by its name.
has_action(name): Returns True if an action with the given name exists.
get_action(name): Returns the action matching the name, or None.
"""
def __init__(self) -> None:
self.actions: list[BaseAction] = []
def set_actions(self, actions: Sequence[BaseAction]) -> None:
"""Replaces the current action list with a new one."""
self.actions.clear()
for action in actions:
self.add_action(action)
def add_action(self, action: BaseAction) -> None:
"""Adds an action to the list."""
self.actions.append(action)
def remove_action(self, name: str) -> None:
"""Removes all actions with the given 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 a single action with the given name."""
for action in self.actions:
if action.name == name:
return action
return None

View File

@ -0,0 +1,205 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines strongly-typed enums used throughout the Falyx CLI framework for
representing common structured values like file formats, selection return types,
and confirmation modes.
These enums support alias resolution, graceful coercion from string inputs,
and are used for input validation, serialization, and CLI configuration parsing.
Exports:
- FileType: Defines supported file formats for `LoadFileAction` and `SaveFileAction`
- SelectionReturnType: Defines structured return modes for `SelectionAction`
- ConfirmType: Defines selectable confirmation types for prompts and guards
Key Features:
- Custom `_missing_()` methods for forgiving string coercion and error reporting
- Aliases and normalization support for user-friendly config-driven workflows
- Useful in CLI flag parsing, YAML configs, and dynamic schema validation
Example:
FileType("yml") → FileType.YAML
SelectionReturnType("value") → SelectionReturnType.VALUE
ConfirmType("YES_NO") → ConfirmType.YES_NO
"""
from __future__ import annotations
from enum import Enum
class FileType(Enum):
"""
Represents supported file types for reading and writing in Falyx Actions.
Used by `LoadFileAction` and `SaveFileAction` to determine how to parse or
serialize file content. Includes alias resolution for common extensions like
`.yml`, `.txt`, and `filepath`.
Members:
TEXT: Raw encoded text as a string.
PATH: Returns the file path (as a Path object).
JSON: JSON-formatted object.
TOML: TOML-formatted object.
YAML: YAML-formatted object.
CSV: List of rows (as lists) from a CSV file.
TSV: Same as CSV, but tab-delimited.
XML: Raw XML as a ElementTree.
Example:
FileType("yml") → FileType.YAML
"""
TEXT = "text"
PATH = "path"
JSON = "json"
TOML = "toml"
YAML = "yaml"
CSV = "csv"
TSV = "tsv"
XML = "xml"
@classmethod
def choices(cls) -> list[FileType]:
"""Return a list of all hook type choices."""
return list(cls)
@classmethod
def _get_alias(cls, value: str) -> str:
aliases = {
"yml": "yaml",
"txt": "text",
"file": "path",
"filepath": "path",
}
return aliases.get(value, value)
@classmethod
def _missing_(cls, value: object) -> FileType:
if not isinstance(value, str):
raise ValueError(f"Invalid {cls.__name__}: {value!r}")
normalized = value.strip().lower()
alias = cls._get_alias(normalized)
for member in cls:
if member.value == alias:
return member
valid = ", ".join(member.value for member in cls)
raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}")
def __str__(self) -> str:
"""Return the string representation of the confirm type."""
return self.value
class SelectionReturnType(Enum):
"""
Controls what is returned from a `SelectionAction` when using a selection map.
Determines how the user's choice(s) from a `dict[str, SelectionOption]` are
transformed and returned by the action.
Members:
KEY: Return the selected key(s) only.
VALUE: Return the value(s) associated with the selected key(s).
DESCRIPTION: Return the description(s) of the selected item(s).
DESCRIPTION_VALUE: Return a dict of {description: value} pairs.
ITEMS: Return full `SelectionOption` objects as a dict {key: SelectionOption}.
Example:
return_type=SelectionReturnType.VALUE → returns raw values like 'prod'
"""
KEY = "key"
VALUE = "value"
DESCRIPTION = "description"
DESCRIPTION_VALUE = "description_value"
ITEMS = "items"
@classmethod
def choices(cls) -> list[SelectionReturnType]:
"""Return a list of all hook type choices."""
return list(cls)
@classmethod
def _get_alias(cls, value: str) -> str:
aliases = {
"desc": "description",
"desc_value": "description_value",
}
return aliases.get(value, value)
@classmethod
def _missing_(cls, value: object) -> SelectionReturnType:
if not isinstance(value, str):
raise ValueError(f"Invalid {cls.__name__}: {value!r}")
normalized = value.strip().lower()
alias = cls._get_alias(normalized)
for member in cls:
if member.value == alias:
return member
valid = ", ".join(member.value for member in cls)
raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}")
def __str__(self) -> str:
"""Return the string representation of the confirm type."""
return self.value
class ConfirmType(Enum):
"""
Enum for defining prompt styles in confirmation dialogs.
Used by confirmation actions to control user input behavior and available choices.
Members:
YES_NO: Prompt with Yes / No options.
YES_CANCEL: Prompt with Yes / Cancel options.
YES_NO_CANCEL: Prompt with Yes / No / Cancel options.
TYPE_WORD: Require user to type a specific confirmation word (e.g., "delete").
TYPE_WORD_CANCEL: Same as TYPE_WORD, but allows cancellation.
OK_CANCEL: Prompt with OK / Cancel options.
ACKNOWLEDGE: Single confirmation button (e.g., "Acknowledge").
Example:
ConfirmType("yes_no_cancel") → ConfirmType.YES_NO_CANCEL
"""
YES_NO = "yes_no"
YES_CANCEL = "yes_cancel"
YES_NO_CANCEL = "yes_no_cancel"
TYPE_WORD = "type_word"
TYPE_WORD_CANCEL = "type_word_cancel"
OK_CANCEL = "ok_cancel"
ACKNOWLEDGE = "acknowledge"
@classmethod
def choices(cls) -> list[ConfirmType]:
"""Return a list of all hook type choices."""
return list(cls)
@classmethod
def _get_alias(cls, value: str) -> str:
aliases = {
"yes": "yes_no",
"ok": "ok_cancel",
"type": "type_word",
"word": "type_word",
"word_cancel": "type_word_cancel",
"ack": "acknowledge",
}
return aliases.get(value, value)
@classmethod
def _missing_(cls, value: object) -> ConfirmType:
if not isinstance(value, str):
raise ValueError(f"Invalid {cls.__name__}: {value!r}")
normalized = value.strip().lower()
alias = cls._get_alias(normalized)
for member in cls:
if member.value == alias:
return member
valid = ", ".join(member.value for member in cls)
raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}")
def __str__(self) -> str:
"""Return the string representation of the confirm type."""
return self.value

187
falyx/action/base_action.py Normal file
View File

@ -0,0 +1,187 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Core action system for Falyx.
This module defines the building blocks for executable actions and workflows,
providing a structured way to compose, execute, recover, and manage sequences of
operations.
All actions are callable and follow a unified signature:
result = action(*args, **kwargs)
Core guarantees:
- Full hook lifecycle support (before, on_success, on_error, after, on_teardown).
- Consistent timing and execution context tracking for each run.
- Unified, predictable result handling and error propagation.
- Optional last_result injection to enable flexible, data-driven workflows.
- Built-in support for retries, rollbacks, parallel groups, chaining, and fallback
recovery.
Key components:
- Action: wraps a function or coroutine into a standard executable unit.
- ChainedAction: runs actions sequentially, optionally injecting last results.
- ActionGroup: runs actions in parallel and gathers results.
- ProcessAction: executes CPU-bound functions in a separate process.
- LiteralInputAction: injects static values into workflows.
- FallbackAction: gracefully recovers from failures or missing data.
This design promotes clean, fault-tolerant, modular CLI and automation systems.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, Callable
from rich.console import Console
from rich.tree import Tree
from falyx.console import console
from falyx.context import SharedContext
from falyx.debug import register_debug_hooks
from falyx.hook_manager import Hook, HookManager, HookType
from falyx.hooks import spinner_before_hook, spinner_teardown_hook
from falyx.logger import logger
from falyx.options_manager import OptionsManager
from falyx.themes import OneColors
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.
Args:
name (str): Name of the action. Used for logging and debugging.
hooks (HookManager | None): Hook manager for lifecycle events.
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').
never_prompt (bool | None): Whether to never prompt for input.
logging_hooks (bool): Whether to register debug hooks for logging.
ignore_in_history (bool): Whether to ignore this action in execution history last result.
"""
def __init__(
self,
name: str,
*,
hooks: HookManager | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
never_prompt: bool | None = None,
logging_hooks: bool = False,
ignore_in_history: bool = False,
spinner: bool = False,
spinner_message: str = "Processing...",
spinner_type: str = "dots",
spinner_style: str = OneColors.CYAN,
spinner_speed: float = 1.0,
) -> 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 | None = never_prompt
self._skip_in_chain: bool = False
self.console: Console = console
self.options_manager: OptionsManager | None = None
self.ignore_in_history: bool = ignore_in_history
self.spinner_message = spinner_message
self.spinner_type = spinner_type
self.spinner_style = spinner_style
self.spinner_speed = spinner_speed
if spinner:
self.hooks.register(HookType.BEFORE, spinner_before_hook)
self.hooks.register(HookType.ON_TEARDOWN, spinner_teardown_hook)
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:
if self._never_prompt is not None:
return self._never_prompt
return self.get_option("never_prompt", False)
@property
def spinner_manager(self):
"""Shortcut to access SpinnerManager via the OptionsManager."""
if not self.options_manager:
raise RuntimeError("SpinnerManager is not available (no OptionsManager set).")
return self.options_manager.spinners
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)

View File

@ -0,0 +1,322 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `ChainedAction`, a core Falyx construct for executing a sequence of actions
in strict order, optionally injecting results from previous steps into subsequent ones.
`ChainedAction` is designed for linear workflows where each step may depend on
the output of the previous one. It supports rollback semantics, fallback recovery,
and advanced error handling using `SharedContext`. Literal values are supported via
automatic wrapping with `LiteralInputAction`.
Key Features:
- Executes a list of actions sequentially
- Optional `auto_inject` to forward `last_result` into each step
- Supports fallback recovery using `FallbackAction` when an error occurs
- Rollback stack to undo already-completed actions on failure
- Integrates with the full Falyx hook lifecycle
- Previews and introspects workflow structure via `Rich`
Use Cases:
- Ordered pipelines (e.g., build → test → deploy)
- Data transformations or ETL workflows
- Linear decision trees or interactive wizards
Special Behaviors:
- Literal inputs (e.g., strings, numbers) are converted to `LiteralInputAction`
- If an action raises and is followed by a `FallbackAction`, it will be skipped and recovered
- If a `BreakChainSignal` is raised, the chain stops early and rollbacks are triggered
Raises:
- `EmptyChainError`: If no actions are present
- `BreakChainSignal`: When explicitly triggered by a child action
- `Exception`: For all unhandled failures during chained execution
Example:
ChainedAction(
name="DeployFlow",
actions=[
ActionGroup(
name="PreDeploymentChecks",
actions=[
Action(
name="ValidateInputs",
action=validate_inputs,
),
Action(
name="CheckDependencies",
action=check_dependencies,
),
],
),
Action(
name="BuildArtifact",
action=build_artifact,
),
Action(
name="Upload",
action=upload,
),
Action(
name="NotifySuccess",
action=notify_success,
),
],
auto_inject=True,
)
"""
from __future__ import annotations
from typing import Any, Awaitable, Callable, Sequence
from rich.tree import Tree
from falyx.action.action import Action
from falyx.action.action_mixins import ActionListMixin
from falyx.action.base_action import BaseAction
from falyx.action.fallback_action import FallbackAction
from falyx.action.literal_input_action import LiteralInputAction
from falyx.context import ExecutionContext, SharedContext
from falyx.exceptions import EmptyChainError
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import Hook, HookManager, HookType
from falyx.logger import logger
from falyx.options_manager import OptionsManager
from falyx.signals import BreakChainSignal
from falyx.themes import OneColors
class ChainedAction(BaseAction, ActionListMixin):
"""
ChainedAction executes a sequence of actions one after another.
Features:
- Supports optional automatic last_result injection (auto_inject).
- Recovers from intermediate errors using FallbackAction if present.
- Rolls back all previously executed actions if a failure occurs.
- Handles literal values with LiteralInputAction.
Best used for defining robust, ordered workflows where each step can depend on
previous results.
Args:
name (str): Name of the chain. Used for logging and debugging.
actions (list): List of actions or literals to execute.
args (tuple, optional): Positional arguments.
kwargs (dict, optional): Keyword arguments.
hooks (HookManager, optional): Hooks for lifecycle events.
inject_last_result (bool, optional): Whether to inject last results into kwargs
by default.
inject_into (str, optional): Key name for injection.
auto_inject (bool, optional): Auto-enable injection for subsequent actions.
return_list (bool, optional): Whether to return a list of all results. False
returns the last result.
"""
def __init__(
self,
name: str,
actions: (
Sequence[BaseAction | Callable[..., Any] | Callable[..., Awaitable[Any]]]
| None
) = None,
*,
args: tuple[Any, ...] = (),
kwargs: dict[str, Any] | None = None,
hooks: HookManager | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
auto_inject: bool = False,
return_list: bool = False,
never_prompt: bool | None = None,
logging_hooks: bool = False,
spinner: bool = False,
spinner_message: str = "Processing...",
spinner_type: str = "dots",
spinner_style: str = OneColors.CYAN,
spinner_speed: float = 1.0,
) -> None:
super().__init__(
name,
hooks=hooks,
inject_last_result=inject_last_result,
inject_into=inject_into,
never_prompt=never_prompt,
logging_hooks=logging_hooks,
spinner=spinner,
spinner_message=spinner_message,
spinner_type=spinner_type,
spinner_style=spinner_style,
spinner_speed=spinner_speed,
)
ActionListMixin.__init__(self)
self.args = args
self.kwargs = kwargs or {}
self.auto_inject = auto_inject
self.return_list = return_list
if actions:
self.set_actions(actions)
def _wrap_if_needed(self, action: BaseAction | Callable[..., Any]) -> BaseAction:
if isinstance(action, BaseAction):
return action
elif callable(action):
return Action(name=action.__name__, action=action)
else:
return LiteralInputAction(action)
def add_action(self, action: BaseAction | Callable[..., Any]) -> None:
action = self._wrap_if_needed(action)
if self.actions and self.auto_inject and not action.inject_last_result:
action.inject_last_result = True
super().add_action(action)
if hasattr(action, "register_teardown") and callable(action.register_teardown):
action.register_teardown(self.hooks)
def set_actions(self, actions: Sequence[BaseAction | Callable[..., Any]]) -> None:
"""Replaces the current action list with a new one."""
self.actions.clear()
for action in actions:
self.add_action(action)
def set_options_manager(self, options_manager: OptionsManager) -> None:
super().set_options_manager(options_manager)
for action in self.actions:
action.set_options_manager(options_manager)
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
if self.actions:
return self.actions[0].get_infer_target()
return None, None
def _clear_args(self):
return (), {}
async def _run(self, *args, **kwargs) -> Any:
if not self.actions:
raise EmptyChainError(f"[{self.name}] No actions to execute.")
combined_args = args + self.args
combined_kwargs = {**self.kwargs, **kwargs}
shared_context = SharedContext(name=self.name, action=self)
if self.shared_context:
shared_context.add_result(self.shared_context.last_result())
updated_kwargs = self._maybe_inject_last_result(combined_kwargs)
context = ExecutionContext(
name=self.name,
args=combined_args,
kwargs=updated_kwargs,
action=self,
extra={"results": [], "rollback_stack": []},
shared_context=shared_context,
)
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
for index, action in enumerate(self.actions):
if action._skip_in_chain:
logger.debug(
"[%s] Skipping consumed action '%s'", self.name, action.name
)
continue
shared_context.current_index = index
prepared = action.prepare(shared_context, self.options_manager)
try:
result = await prepared(*combined_args, **updated_kwargs)
except Exception as error:
if index + 1 < len(self.actions) and isinstance(
self.actions[index + 1], FallbackAction
):
logger.warning(
"[%s] Fallback triggered: %s, recovering with fallback "
"'%s'.",
self.name,
error,
self.actions[index + 1].name,
)
shared_context.add_result(None)
context.extra["results"].append(None)
fallback = self.actions[index + 1].prepare(shared_context)
result = await fallback()
fallback._skip_in_chain = True
else:
raise
shared_context.add_result(result)
context.extra["results"].append(result)
context.extra["rollback_stack"].append(
(prepared, combined_args, updated_kwargs)
)
combined_args, updated_kwargs = self._clear_args()
all_results = context.extra["results"]
assert (
all_results
), f"[{self.name}] No results captured. Something seriously went wrong."
context.result = all_results if self.return_list else all_results[-1]
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return context.result
except BreakChainSignal as error:
logger.info("[%s] Chain broken: %s", self.name, error)
context.exception = error
shared_context.add_error(shared_context.current_index, error)
await self._rollback(context.extra["rollback_stack"])
except Exception as error:
context.exception = error
shared_context.add_error(shared_context.current_index, error)
await self._rollback(context.extra["rollback_stack"])
await self.hooks.trigger(HookType.ON_ERROR, context)
raise
finally:
context.stop_timer()
await self.hooks.trigger(HookType.AFTER, context)
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
er.record(context)
async def _rollback(
self, rollback_stack: list[tuple[Action, tuple[Any, ...], dict[str, Any]]]
):
"""
Roll back all executed actions in reverse order.
Rollbacks run even if a fallback recovered from failure,
ensuring consistent undo of all side effects.
Actions without rollback handlers are skipped.
Args:
rollback_stack (list): Actions to roll back.
*args, **kwargs: Passed to rollback handlers.
"""
for action, args, kwargs in reversed(rollback_stack):
rollback = getattr(action, "rollback", None)
if rollback:
try:
logger.warning("[%s] Rolling back...", action.name)
await rollback(*args, **kwargs)
except Exception as error:
logger.error("[%s] Rollback failed: %s", action.name, error)
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
"""Register a hook for all actions and sub-actions."""
super().register_hooks_recursively(hook_type, hook)
for action in self.actions:
action.register_hooks_recursively(hook_type, hook)
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.CYAN_b}]⛓ ChainedAction[/] '{self.name}'"]
if self.inject_last_result:
label.append(f" [dim](injects '{self.inject_into}')[/dim]")
tree = parent.add("".join(label)) if parent else Tree("".join(label))
for action in self.actions:
await action.preview(parent=tree)
if not parent:
self.console.print(tree)
def __str__(self):
return (
f"ChainedAction(name={self.name}, "
f"actions={[a.name for a in self.actions]}, "
f"args={self.args!r}, kwargs={self.kwargs!r}, "
f"auto_inject={self.auto_inject}, return_list={self.return_list})"
)

View File

@ -0,0 +1,272 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `ConfirmAction`, a Falyx Action that prompts the user for confirmation
before continuing execution.
`ConfirmAction` supports a wide range of confirmation strategies, including:
- Yes/No-style prompts
- OK/Cancel dialogs
- Typed confirmation (e.g., "CONFIRM" or "DELETE")
- Acknowledge-only flows
It is useful for adding safety gates, user-driven approval steps, or destructive
operation guards in CLI workflows. This Action supports both interactive use and
non-interactive (headless) behavior via `never_prompt`, as well as full hook lifecycle
integration and optional result passthrough.
Key Features:
- Supports all common confirmation types (see `ConfirmType`)
- Integrates with `PromptSession` for prompt_toolkit-based UX
- Configurable fallback word validation and behavior on cancel
- Can return the injected `last_result` instead of a boolean
- Fully compatible with Falyx hooks, preview, and result injection
Use Cases:
- Safety checks before deleting, pushing, or overwriting resources
- Gatekeeping interactive workflows
- Validating irreversible or sensitive operations
Example:
ConfirmAction(
name="ConfirmDeploy",
message="Are you sure you want to deploy to production?",
confirm_type="yes_no_cancel",
)
Raises:
- `CancelSignal`: When the user chooses to abort the action
- `ValueError`: If an invalid `confirm_type` is provided
"""
from __future__ import annotations
from typing import Any
from prompt_toolkit import PromptSession
from rich.tree import Tree
from falyx.action.action_types import ConfirmType
from falyx.action.base_action import BaseAction
from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType
from falyx.logger import logger
from falyx.prompt_utils import (
confirm_async,
rich_text_to_prompt_text,
should_prompt_user,
)
from falyx.signals import CancelSignal
from falyx.themes import OneColors
from falyx.validators import word_validator, words_validator
class ConfirmAction(BaseAction):
"""
Action to confirm an operation with the user.
There are several ways to confirm an action, such as using a simple
yes/no prompt. You can also use a confirmation type that requires the user
to type a specific word or phrase to confirm the action, or use an OK/Cancel
dialog.
This action can be used to ensure that the user explicitly agrees to proceed
with an operation.
Attributes:
name (str): Name of the action. Used for logging and debugging.
prompt_message (str): The confirmation message to display.
confirm_type (ConfirmType | str): The type of confirmation to use.
Options include YES_NO, YES_CANCEL, YES_NO_CANCEL, TYPE_WORD, and OK_CANCEL.
prompt_session (PromptSession | None): The session to use for input.
confirm (bool): Whether to prompt the user for confirmation.
word (str): The word to type for TYPE_WORD confirmation.
return_last_result (bool): Whether to return the last result of the action
instead of a boolean.
"""
def __init__(
self,
name: str,
prompt_message: str = "Confirm?",
confirm_type: ConfirmType | str = ConfirmType.YES_NO,
prompt_session: PromptSession | None = None,
never_prompt: bool = False,
word: str = "CONFIRM",
return_last_result: bool = False,
inject_last_result: bool = True,
inject_into: str = "last_result",
):
"""
Initialize the ConfirmAction.
Args:
message (str): The confirmation message to display.
confirm_type (ConfirmType): The type of confirmation to use.
Options include YES_NO, YES_CANCEL, YES_NO_CANCEL, TYPE_WORD, and OK_CANCEL.
prompt_session (PromptSession | None): The session to use for input.
confirm (bool): Whether to prompt the user for confirmation.
word (str): The word to type for TYPE_WORD confirmation.
return_last_result (bool): Whether to return the last result of the action.
"""
super().__init__(
name=name,
inject_last_result=inject_last_result,
inject_into=inject_into,
never_prompt=never_prompt,
)
self.prompt_message = prompt_message
self.confirm_type = ConfirmType(confirm_type)
self.prompt_session = prompt_session or PromptSession(
interrupt_exception=CancelSignal
)
self.word = word
self.return_last_result = return_last_result
async def _confirm(self) -> bool:
"""Confirm the action with the user."""
match self.confirm_type:
case ConfirmType.YES_NO:
return await confirm_async(
rich_text_to_prompt_text(self.prompt_message),
suffix=rich_text_to_prompt_text(
f" [[{OneColors.GREEN_b}]Y[/]]es, "
f"[[{OneColors.DARK_RED_b}]N[/]]o > "
),
session=self.prompt_session,
)
case ConfirmType.YES_NO_CANCEL:
error_message = "Enter 'Y', 'y' to confirm, 'N', 'n' to decline, or 'C', 'c' to abort."
answer = await self.prompt_session.prompt_async(
rich_text_to_prompt_text(
f"{self.prompt_message} [[{OneColors.GREEN_b}]Y[/]]es, "
f"[[{OneColors.DARK_YELLOW_b}]N[/]]o, "
f"or [[{OneColors.DARK_RED_b}]C[/]]ancel to abort > "
),
validator=words_validator(
["Y", "N", "C"], error_message=error_message
),
)
if answer.upper() == "C":
raise CancelSignal(f"Action '{self.name}' was cancelled by the user.")
return answer.upper() == "Y"
case ConfirmType.TYPE_WORD:
answer = await self.prompt_session.prompt_async(
rich_text_to_prompt_text(
f"{self.prompt_message} [[{OneColors.GREEN_b}]{self.word.upper()}[/]] "
f"to confirm or [[{OneColors.DARK_RED}]N[/{OneColors.DARK_RED}]] > "
),
validator=word_validator(self.word),
)
return answer.upper().strip() != "N"
case ConfirmType.TYPE_WORD_CANCEL:
answer = await self.prompt_session.prompt_async(
rich_text_to_prompt_text(
f"{self.prompt_message} [[{OneColors.GREEN_b}]{self.word.upper()}[/]] "
f"to confirm or [[{OneColors.DARK_RED}]N[/{OneColors.DARK_RED}]] > "
),
validator=word_validator(self.word),
)
if answer.upper().strip() == "N":
raise CancelSignal(f"Action '{self.name}' was cancelled by the user.")
return answer.upper().strip() == self.word.upper().strip()
case ConfirmType.YES_CANCEL:
answer = await confirm_async(
rich_text_to_prompt_text(self.prompt_message),
suffix=rich_text_to_prompt_text(
f" [[{OneColors.GREEN_b}]Y[/]]es, "
f"[[{OneColors.DARK_RED_b}]N[/]]o > "
),
session=self.prompt_session,
)
if not answer:
raise CancelSignal(f"Action '{self.name}' was cancelled by the user.")
return answer
case ConfirmType.OK_CANCEL:
error_message = "Enter 'O', 'o' to confirm or 'C', 'c' to abort."
answer = await self.prompt_session.prompt_async(
rich_text_to_prompt_text(
f"{self.prompt_message} [[{OneColors.GREEN_b}]O[/]]k to confirm, "
f"[[{OneColors.DARK_RED}]C[/]]ancel to abort > "
),
validator=words_validator(["O", "C"], error_message=error_message),
)
if answer.upper() == "C":
raise CancelSignal(f"Action '{self.name}' was cancelled by the user.")
return answer.upper() == "O"
case ConfirmType.ACKNOWLEDGE:
answer = await self.prompt_session.prompt_async(
rich_text_to_prompt_text(
f"{self.prompt_message} [[{OneColors.CYAN_b}]A[/]]cknowledge > "
),
validator=word_validator("A"),
)
return answer.upper().strip() == "A"
case _:
raise ValueError(f"Unknown confirm_type: {self.confirm_type}")
def get_infer_target(self) -> tuple[None, None]:
return None, None
async def _run(self, *args, **kwargs) -> Any:
combined_kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext(
name=self.name, args=args, kwargs=combined_kwargs, action=self
)
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
if (
self.never_prompt
or self.options_manager
and not should_prompt_user(confirm=True, options=self.options_manager)
):
logger.debug(
"Skipping confirmation for '%s' due to never_prompt or options_manager settings.",
self.name,
)
if self.return_last_result:
result = combined_kwargs[self.inject_into]
else:
result = True
else:
answer = await self._confirm()
if self.return_last_result and answer:
result = combined_kwargs[self.inject_into]
else:
result = answer
logger.debug("Action '%s' confirmed with result: %s", self.name, result)
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return result
except Exception as error:
context.exception = error
await self.hooks.trigger(HookType.ON_ERROR, context)
raise
finally:
context.stop_timer()
await self.hooks.trigger(HookType.AFTER, context)
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
er.record(context)
async def preview(self, parent: Tree | None = None) -> None:
tree = (
Tree(
f"[{OneColors.CYAN_b}]ConfirmAction[/]: {self.name}",
guide_style=OneColors.BLUE_b,
)
if not parent
else parent.add(f"[{OneColors.CYAN_b}]ConfirmAction[/]: {self.name}")
)
tree.add(f"[bold]Message:[/] {self.prompt_message}")
tree.add(f"[bold]Type:[/] {self.confirm_type.value}")
tree.add(f"[bold]Prompt Required:[/] {'No' if self.never_prompt else 'Yes'}")
if self.confirm_type in (ConfirmType.TYPE_WORD, ConfirmType.TYPE_WORD_CANCEL):
tree.add(f"[bold]Confirmation Word:[/] {self.word}")
if parent is None:
self.console.print(tree)
def __str__(self) -> str:
return (
f"ConfirmAction(name={self.name}, message={self.prompt_message}, "
f"confirm_type={self.confirm_type}, return_last_result={self.return_last_result})"
)

View File

@ -0,0 +1,87 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `FallbackAction`, a lightweight recovery Action used within `ChainedAction`
pipelines to gracefully handle errors or missing results from a preceding step.
When placed immediately after a failing or null-returning Action, `FallbackAction`
injects the `last_result` and checks whether it is `None`. If so, it substitutes a
predefined fallback value and allows the chain to continue. If `last_result` is valid,
it is passed through unchanged.
This mechanism allows workflows to recover from failure or gaps in data
without prematurely terminating the entire chain.
Key Features:
- Injects and inspects `last_result`
- Replaces `None` with a fallback value
- Consumes upstream errors when used with `ChainedAction`
- Fully compatible with Falyx's preview and hook systems
Typical Use Cases:
- Graceful degradation in chained workflows
- Providing default values when earlier steps are optional
- Replacing missing data with static or precomputed values
Example:
ChainedAction(
name="FetchWithFallback",
actions=[
Action("MaybeFetchRemoteAction", action=fetch_data),
FallbackAction(fallback={"data": "default"}),
Action("ProcessDataAction", action=process_data),
],
auto_inject=True,
)
The `FallbackAction` ensures that even if `MaybeFetchRemoteAction` fails or returns
None, `ProcessDataAction` still receives a usable input.
"""
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})"

View File

@ -1,5 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""http_action.py
"""
Defines an Action subclass for making HTTP requests using aiohttp within Falyx workflows.
Features:
@ -28,7 +28,7 @@ async def close_shared_http_session(context: ExecutionContext) -> None:
if session and should_close:
await session.close()
except Exception as error:
logger.warning("⚠️ Error closing shared HTTP session: %s", error)
logger.warning("Error closing shared HTTP session: %s", error)
class HTTPAction(Action):
@ -47,7 +47,7 @@ class HTTPAction(Action):
- Retry and result injection compatible
Args:
name (str): Name of the action.
name (str): Name of the action. Used for logging and debugging.
method (str): HTTP method (e.g., 'GET', 'POST').
url (str): The request URL.
headers (dict[str, str], optional): Request headers.
@ -77,6 +77,11 @@ class HTTPAction(Action):
inject_into: str = "last_result",
retry: bool = False,
retry_policy=None,
spinner: bool = False,
spinner_message: str = "Processing...",
spinner_type: str = "dots",
spinner_style: str = OneColors.CYAN,
spinner_speed: float = 1.0,
):
self.method = method.upper()
self.url = url
@ -95,6 +100,11 @@ class HTTPAction(Action):
inject_into=inject_into,
retry=retry,
retry_policy=retry_policy,
spinner=spinner,
spinner_message=spinner_message,
spinner_type=spinner_type,
spinner_style=spinner_style,
spinner_speed=spinner_speed,
)
async def _request(self, *_, **__) -> dict[str, Any]:

View File

@ -1,5 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""io_action.py
"""
BaseIOAction: A base class for stream- or buffer-based IO-driven Actions.
This module defines `BaseIOAction`, a specialized variant of `BaseAction`
@ -16,19 +16,15 @@ Common usage includes shell-like filters, input transformers, or any tool that
needs to consume input from another process or pipeline.
"""
import asyncio
import shlex
import subprocess
import sys
from typing import Any
from typing import Any, Callable
from rich.tree import Tree
from falyx.action.action import BaseAction
from falyx.action.base_action import BaseAction
from falyx.context import ExecutionContext
from falyx.exceptions import FalyxError
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType
from falyx.logger import logger
from falyx.themes import OneColors
@ -52,8 +48,11 @@ class BaseIOAction(BaseAction):
- `to_output(data)`: Convert result into output string or bytes.
- `_run(parsed_input, *args, **kwargs)`: Core execution logic.
Attributes:
Args:
name (str): Name of the action. Used for logging and debugging.
hooks (HookManager | None): Hook manager for lifecycle events.
mode (str): Either "buffered" or "stream". Controls input behavior.
logging_hooks (bool): Whether to register debug hooks for logging.
inject_last_result (bool): Whether to inject shared context input.
"""
@ -73,7 +72,6 @@ class BaseIOAction(BaseAction):
inject_last_result=inject_last_result,
)
self.mode = mode
self._requires_injection = True
def from_input(self, raw: str | bytes) -> Any:
raise NotImplementedError
@ -81,23 +79,23 @@ class BaseIOAction(BaseAction):
def to_output(self, result: Any) -> str | bytes:
raise NotImplementedError
async def _resolve_input(self, kwargs: dict[str, Any]) -> str | bytes:
last_result = kwargs.pop(self.inject_into, None)
async def _resolve_input(
self, args: tuple[Any], kwargs: dict[str, Any]
) -> str | bytes:
data = await self._read_stdin()
if data:
return self.from_input(data)
if last_result is not None:
return last_result
if len(args) == 1:
return self.from_input(args[0])
if self.inject_last_result and self.shared_context:
return self.shared_context.last_result()
logger.debug(
"[%s] No input provided and no last result found for injection.", self.name
)
raise FalyxError("No input provided and no last result to inject.")
return ""
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
return None, None
async def __call__(self, *args, **kwargs):
context = ExecutionContext(
@ -117,8 +115,8 @@ class BaseIOAction(BaseAction):
pass
result = getattr(self, "_last_result", None)
else:
parsed_input = await self._resolve_input(kwargs)
result = await self._run(parsed_input, *args, **kwargs)
parsed_input = await self._resolve_input(args, kwargs)
result = await self._run(parsed_input)
output = self.to_output(result)
await self._write_stdout(output)
context.result = result
@ -172,88 +170,3 @@ class BaseIOAction(BaseAction):
parent.add("".join(label))
else:
self.console.print(Tree("".join(label)))
class ShellAction(BaseIOAction):
"""
ShellAction wraps a shell command template for CLI pipelines.
This Action takes parsed input (from stdin, literal, or last_result),
substitutes it into the provided shell command template, and executes
the command asynchronously using subprocess.
Designed for quick integration with shell tools like `grep`, `ping`, `jq`, etc.
⚠️ Security Warning:
By default, ShellAction uses `shell=True`, which can be dangerous with
unsanitized input. To mitigate this, set `safe_mode=True` to use `shell=False`
with `shlex.split()`.
Features:
- Automatically handles input parsing (str/bytes)
- `safe_mode=True` disables shell interpretation and runs with `shell=False`
- Captures stdout and stderr from shell execution
- Raises on non-zero exit codes with stderr as the error
- Result is returned as trimmed stdout string
- Compatible with ChainedAction and Command.requires_input detection
Args:
name (str): Name of the action.
command_template (str): Shell command to execute. Must include `{}` to include
input. If no placeholder is present, the input is not
included.
safe_mode (bool): If True, runs with `shell=False` using shlex parsing
(default: False).
"""
def __init__(
self, name: str, command_template: str, safe_mode: bool = False, **kwargs
):
super().__init__(name=name, **kwargs)
self.command_template = command_template
self.safe_mode = safe_mode
def from_input(self, raw: str | bytes) -> str:
if not isinstance(raw, (str, bytes)):
raise TypeError(
f"{self.name} expected str or bytes input, got {type(raw).__name__}"
)
return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip()
async def _run(self, parsed_input: str) -> str:
# Replace placeholder in template, or use raw input as full command
command = self.command_template.format(parsed_input)
if self.safe_mode:
try:
args = shlex.split(command)
except ValueError as error:
raise FalyxError(f"Invalid command template: {error}")
result = subprocess.run(args, capture_output=True, text=True, check=True)
else:
result = subprocess.run(
command, shell=True, text=True, capture_output=True, check=True
)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip())
return result.stdout.strip()
def to_output(self, result: str) -> str:
return result
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.GREEN_b}]⚙ ShellAction[/] '{self.name}'"]
label.append(f"\n[dim]Template:[/] {self.command_template}")
label.append(
f"\n[dim]Safe mode:[/] {'Enabled' if self.safe_mode else 'Disabled'}"
)
if self.inject_last_result:
label.append(f" [dim](injects '{self.inject_into}')[/dim]")
tree = parent.add("".join(label)) if parent else Tree("".join(label))
if not parent:
self.console.print(tree)
def __str__(self):
return (
f"ShellAction(name={self.name!r}, command_template={self.command_template!r},"
f" safe_mode={self.safe_mode})"
)

View File

@ -0,0 +1,80 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `LiteralInputAction`, a lightweight Falyx Action that injects a static,
predefined value into a `ChainedAction` workflow.
This Action is useful for embedding literal values (e.g., strings, numbers,
dicts) as part of a CLI pipeline without writing custom callables. It behaves
like a constant-returning function that can serve as the starting point,
fallback, or manual override within a sequence of actions.
Key Features:
- Wraps any static value as a Falyx-compatible Action
- Fully hookable and previewable like any other Action
- Enables declarative workflows with no required user input
- Compatible with auto-injection and shared context in `ChainedAction`
Common Use Cases:
- Supplying default parameters or configuration values mid-pipeline
- Starting a chain with a fixed value (e.g., base URL, credentials)
- Bridging gaps between conditional or dynamically generated Actions
Example:
ChainedAction(
name="SendStaticMessage",
actions=[
LiteralInputAction("hello world"),
SendMessageAction(),
]
)
The `LiteralInputAction` is a foundational building block for pipelines that
require predictable, declarative value injection at any stage.
"""
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})"

View File

@ -0,0 +1,264 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `LoadFileAction`, a Falyx Action for reading and parsing the contents of a file
at runtime in a structured, introspectable, and lifecycle-aware manner.
This action supports multiple common file types—including plain text, structured data
formats (JSON, YAML, TOML), tabular formats (CSV, TSV), XML, and raw Path objects—
making it ideal for configuration loading, data ingestion, and file-driven workflows.
It integrates seamlessly with Falyx pipelines and supports `last_result` injection,
Rich-powered previews, and lifecycle hook execution.
Key Features:
- Format-aware parsing for structured and unstructured files
- Supports injection of `last_result` as the target file path
- Headless-compatible via `never_prompt` and argument overrides
- Lifecycle hooks: before, success, error, after, teardown
- Preview renders file metadata, size, modified timestamp, and parsed content
- Fully typed and alias-compatible via `FileType`
Supported File Types:
- `TEXT`: Raw text string (UTF-8)
- `PATH`: The file path itself as a `Path` object
- `JSON`, `YAML`, `TOML`: Parsed into `dict` or `list`
- `CSV`, `TSV`: Parsed into `list[list[str]]`
- `XML`: Returns the root `ElementTree.Element`
Example:
LoadFileAction(
name="LoadSettings",
file_path="config/settings.yaml",
file_type="yaml"
)
This module is a foundational building block for file-driven CLI workflows in Falyx.
It is often paired with `SaveFileAction`, `SelectionAction`, or `ConfirmAction` for
robust and interactive pipelines.
"""
import csv
import json
import xml.etree.ElementTree as ET
from datetime import datetime
from pathlib import Path
from typing import Any
import toml
import yaml
from rich.tree import Tree
from falyx.action.action_types import FileType
from falyx.action.base_action import BaseAction
from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType
from falyx.logger import logger
from falyx.themes import OneColors
class LoadFileAction(BaseAction):
"""
LoadFileAction loads and parses the contents of a file at runtime.
This action supports multiple common file formats—including plain text, JSON,
YAML, TOML, XML, CSV, and TSV—and returns a parsed representation of the file.
It can be used to inject external data into a CLI workflow, load configuration files,
or process structured datasets interactively or in headless mode.
Key Features:
- Supports rich previewing of file metadata and contents
- Auto-injects `last_result` as `file_path` if configured
- Hookable at every lifecycle stage (before, success, error, after, teardown)
- Supports both static and dynamic file targets (via args or injected values)
Args:
name (str): Name of the action for tracking and logging.
file_path (str | Path | None): Path to the file to be loaded. Can be passed
directly or injected via `last_result`.
file_type (FileType | str): Type of file to parse. Options include:
TEXT, JSON, YAML, TOML, CSV, TSV, XML, PATH.
encoding (str): Encoding to use when reading files (default: 'UTF-8').
inject_last_result (bool): Whether to use the last result as the file path.
inject_into (str): Name of the kwarg to inject `last_result` into (default: 'file_path').
Returns:
Any: The parsed file content. Format depends on `file_type`:
- TEXT: str
- JSON/YAML/TOML: dict or list
- CSV/TSV: list[list[str]]
- XML: xml.etree.ElementTree
- PATH: Path object
Raises:
ValueError: If `file_path` is missing or invalid.
FileNotFoundError: If the file does not exist.
TypeError: If `file_type` is unsupported or the factory does not return a BaseAction.
Any parsing errors will be logged but not raised unless fatal.
Example:
LoadFileAction(
name="LoadConfig",
file_path="config/settings.yaml",
file_type="yaml"
)
"""
def __init__(
self,
name: str,
file_path: str | Path | None = None,
file_type: FileType | str = FileType.TEXT,
encoding: str = "UTF-8",
inject_last_result: bool = False,
inject_into: str = "file_path",
):
super().__init__(
name=name, inject_last_result=inject_last_result, inject_into=inject_into
)
self._file_path = self._coerce_file_path(file_path)
self._file_type = FileType(file_type)
self.encoding = encoding
@property
def file_path(self) -> Path | None:
"""Get the file path as a Path object."""
return self._file_path
@file_path.setter
def file_path(self, value: str | Path):
"""Set the file path, converting to Path if necessary."""
self._file_path = self._coerce_file_path(value)
def _coerce_file_path(self, file_path: str | Path | None) -> Path | None:
"""Coerce the file path to a Path object."""
if isinstance(file_path, Path):
return file_path
elif isinstance(file_path, str):
return Path(file_path)
elif file_path is None:
return None
else:
raise TypeError("file_path must be a string or Path object")
@property
def file_type(self) -> FileType:
"""Get the file type."""
return self._file_type
def get_infer_target(self) -> tuple[None, None]:
return None, None
async def load_file(self) -> Any:
"""Load and parse the file based on its type."""
if self.file_path is None:
raise ValueError("file_path must be set before loading a file")
elif not self.file_path.exists():
raise FileNotFoundError(f"File not found: {self.file_path}")
elif not self.file_path.is_file():
raise ValueError(f"Path is not a regular file: {self.file_path}")
value: Any = None
try:
if self.file_type == FileType.TEXT:
value = self.file_path.read_text(encoding=self.encoding)
elif self.file_type == FileType.PATH:
value = self.file_path
elif self.file_type == FileType.JSON:
value = json.loads(self.file_path.read_text(encoding=self.encoding))
elif self.file_type == FileType.TOML:
value = toml.loads(self.file_path.read_text(encoding=self.encoding))
elif self.file_type == FileType.YAML:
value = yaml.safe_load(self.file_path.read_text(encoding=self.encoding))
elif self.file_type == FileType.CSV:
with open(self.file_path, newline="", encoding=self.encoding) as csvfile:
reader = csv.reader(csvfile)
value = list(reader)
elif self.file_type == FileType.TSV:
with open(self.file_path, newline="", encoding=self.encoding) as tsvfile:
reader = csv.reader(tsvfile, delimiter="\t")
value = list(reader)
elif self.file_type == FileType.XML:
tree = ET.parse(
self.file_path, parser=ET.XMLParser(encoding=self.encoding)
)
root = tree.getroot()
value = root
else:
raise ValueError(f"Unsupported return type: {self.file_type}")
except Exception as error:
logger.error("Failed to parse %s: %s", self.file_path.name, error)
return value
async def _run(self, *args, **kwargs) -> Any:
context = ExecutionContext(name=self.name, args=args, kwargs=kwargs, action=self)
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
if "file_path" in kwargs:
self.file_path = kwargs["file_path"]
elif self.inject_last_result and self.last_result:
self.file_path = self.last_result
result = await self.load_file()
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return result
except Exception as error:
context.exception = error
await self.hooks.trigger(HookType.ON_ERROR, context)
raise
finally:
context.stop_timer()
await self.hooks.trigger(HookType.AFTER, context)
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
er.record(context)
async def preview(self, parent: Tree | None = None):
label = f"[{OneColors.GREEN}]📄 LoadFileAction[/] '{self.name}'"
tree = parent.add(label) if parent else Tree(label)
tree.add(f"[dim]Path:[/] {self.file_path}")
tree.add(f"[dim]Type:[/] {self.file_type.name if self.file_type else 'None'}")
if self.file_path is None:
tree.add(f"[{OneColors.DARK_RED_b}]❌ File path is not set[/]")
elif not self.file_path.exists():
tree.add(f"[{OneColors.DARK_RED_b}]❌ File does not exist[/]")
elif not self.file_path.is_file():
tree.add(f"[{OneColors.LIGHT_YELLOW_b}]⚠️ Not a regular file[/]")
else:
try:
stat = self.file_path.stat()
tree.add(f"[dim]Size:[/] {stat.st_size:,} bytes")
tree.add(
f"[dim]Modified:[/] {datetime.fromtimestamp(stat.st_mtime):%Y-%m-%d %H:%M:%S}"
)
tree.add(
f"[dim]Created:[/] {datetime.fromtimestamp(stat.st_ctime):%Y-%m-%d %H:%M:%S}"
)
if self.file_type == FileType.TEXT:
preview_lines = self.file_path.read_text(
encoding="UTF-8"
).splitlines()[:10]
content_tree = tree.add("[dim]Preview (first 10 lines):[/]")
for line in preview_lines:
content_tree.add(f"[dim]{line}[/]")
elif self.file_type in {FileType.JSON, FileType.YAML, FileType.TOML}:
raw = self.load_file()
if raw is not None:
preview_str = (
json.dumps(raw, indent=2)
if isinstance(raw, dict)
else str(raw)
)
preview_lines = preview_str.splitlines()[:10]
content_tree = tree.add("[dim]Parsed preview:[/]")
for line in preview_lines:
content_tree.add(f"[dim]{line}[/]")
except Exception as e:
tree.add(f"[{OneColors.DARK_RED_b}]❌ Error reading file:[/] {e}")
if not parent:
self.console.print(tree)
def __str__(self) -> str:
return f"LoadFileAction(file_path={self.file_path}, file_type={self.file_type})"

View File

@ -1,26 +1,113 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""menu_action.py"""
"""
Defines `MenuAction`, a one-shot, interactive menu-style Falyx Action that presents
a set of labeled options to the user and executes the corresponding action based on
their selection.
Unlike the persistent top-level Falyx menu, `MenuAction` is intended for embedded,
self-contained decision points within a workflow. It supports both interactive and
non-interactive (headless) usage, integrates fully with the Falyx hook lifecycle,
and allows optional defaulting or input injection from previous actions.
Each selectable item is defined in a `MenuOptionMap`, mapping a single-character or
keyword to a `MenuOption`, which includes a description and a corresponding `BaseAction`.
Key Features:
- Renders a Rich-powered multi-column menu table
- Accepts custom prompt sessions or tables
- Supports `last_result` injection for context-aware defaults
- Gracefully handles `BackSignal` and `QuitSignal` for flow control
- Compatible with preview trees and introspection tools
Use Cases:
- In-workflow submenus or branches
- Interactive control points in chained or grouped workflows
- Configurable menus for multi-step user-driven automation
Example:
MenuAction(
name="SelectEnv",
menu_options=MenuOptionMap(options={
"D": MenuOption("Deploy to Dev", DeployDevAction()),
"P": MenuOption("Deploy to Prod", DeployProdAction()),
}),
default_selection="D",
)
This module is ideal for enabling structured, discoverable, and declarative
menus in both interactive and programmatic CLI automation.
"""
from typing import Any
from prompt_toolkit import PromptSession
from rich.console import Console
from rich.table import Table
from rich.tree import Tree
from falyx.action.action import BaseAction
from falyx.action.base_action import BaseAction
from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType
from falyx.logger import logger
from falyx.menu import MenuOptionMap
from falyx.prompt_utils import rich_text_to_prompt_text
from falyx.selection import prompt_for_selection, render_table_base
from falyx.signals import BackSignal, QuitSignal
from falyx.signals import BackSignal, CancelSignal, QuitSignal
from falyx.themes import OneColors
from falyx.utils import chunks
class MenuAction(BaseAction):
"""MenuAction class for creating single use menu actions."""
"""
MenuAction displays a one-time interactive menu of predefined options,
each mapped to a corresponding Action.
Unlike the main Falyx menu system, `MenuAction` is intended for scoped,
self-contained selection logic—ideal for small in-flow menus, decision branches,
or embedded control points in larger workflows.
Each selectable item is defined in a `MenuOptionMap`, which maps a string key
to a `MenuOption`, bundling a description and a callable Action.
Key Features:
- One-shot selection from labeled actions
- Optional default or last_result-based selection
- Full hook lifecycle (before, success, error, after, teardown)
- Works with or without rendering a table (for headless use)
- Compatible with `BackSignal` and `QuitSignal` for graceful control flow exits
Args:
name (str): Name of the action. Used for logging and debugging.
menu_options (MenuOptionMap): Mapping of keys to `MenuOption` objects.
title (str): Table title displayed when prompting the user.
columns (int): Number of columns in the rendered table.
prompt_message (str): Prompt text displayed before selection.
default_selection (str): Key to use if no user input is provided.
inject_last_result (bool): Whether to inject `last_result` into args/kwargs.
inject_into (str): Key under which to inject `last_result`.
prompt_session (PromptSession | None): Custom session for Prompt Toolkit input.
never_prompt (bool): If True, skips interaction and uses default or last_result.
include_reserved (bool): Whether to include reserved keys (like 'X' for Exit).
show_table (bool): Whether to render the Rich menu table.
custom_table (Table | None): Pre-rendered Rich Table (bypasses auto-building).
Returns:
Any: The result of the selected option's Action.
Raises:
BackSignal: When the user chooses to return to a previous menu.
QuitSignal: When the user chooses to exit the program.
ValueError: If `never_prompt=True` but no default selection is resolvable.
Exception: Any error raised during the execution of the selected Action.
Example:
MenuAction(
name="ChooseBranch",
menu_options=MenuOptionMap(options={
"A": MenuOption("Run analysis", ActionGroup(...)),
"B": MenuOption("Run report", Action(...)),
}),
)
"""
def __init__(
self,
@ -33,7 +120,6 @@ class MenuAction(BaseAction):
default_selection: str = "",
inject_last_result: bool = False,
inject_into: str = "last_result",
console: Console | None = None,
prompt_session: PromptSession | None = None,
never_prompt: bool = False,
include_reserved: bool = True,
@ -49,10 +135,11 @@ class MenuAction(BaseAction):
self.menu_options = menu_options
self.title = title
self.columns = columns
self.prompt_message = prompt_message
self.prompt_message = rich_text_to_prompt_text(prompt_message)
self.default_selection = default_selection
self.console = console or Console(color_system="auto")
self.prompt_session = prompt_session or PromptSession()
self.prompt_session = prompt_session or PromptSession(
interrupt_exception=CancelSignal
)
self.include_reserved = include_reserved
self.show_table = show_table
self.custom_table = custom_table
@ -73,6 +160,9 @@ class MenuAction(BaseAction):
table.add_row(*row)
return table
def get_infer_target(self) -> tuple[None, None]:
return None, None
async def _run(self, *args, **kwargs) -> Any:
kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext(
@ -105,15 +195,18 @@ class MenuAction(BaseAction):
key = effective_default
if not self.never_prompt:
table = self._build_table()
key = await prompt_for_selection(
key_ = await prompt_for_selection(
self.menu_options.keys(),
table,
default_selection=self.default_selection,
console=self.console,
prompt_session=self.prompt_session,
prompt_message=self.prompt_message,
show_table=self.show_table,
)
if isinstance(key_, str):
key = key_
else:
assert False, "Unreachable, MenuAction only supports single selection"
option = self.menu_options[key]
result = await option.action(*args, **kwargs)
context.result = result
@ -121,10 +214,10 @@ class MenuAction(BaseAction):
return result
except BackSignal:
logger.debug("[%s][BackSignal] Returning to previous menu", self.name)
logger.debug("[%s][BackSignal] <- Returning to previous menu", self.name)
return None
except QuitSignal:
logger.debug("[%s][QuitSignal] Exiting application", self.name)
logger.debug("[%s][QuitSignal] <- Exiting application", self.name)
raise
except Exception as error:
context.exception = error

View File

@ -0,0 +1,181 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `ProcessAction`, a Falyx Action that executes a blocking or CPU-bound function
in a separate process using `concurrent.futures.ProcessPoolExecutor`.
This is useful for offloading expensive computations or subprocess-compatible operations
from the main event loop, while maintaining Falyx's composable, hookable, and injectable
execution model.
`ProcessAction` mirrors the behavior of a normal `Action`, but ensures isolation from
the asyncio event loop and handles serialization (pickling) of arguments and injected
state.
Key Features:
- Runs a callable in a separate Python process
- Compatible with `last_result` injection for chained workflows
- Validates that injected values are pickleable before dispatch
- Supports hook lifecycle (`before`, `on_success`, `on_error`, etc.)
- Custom executor support for reuse or configuration
Use Cases:
- CPU-intensive operations (e.g., image processing, simulations, data transformations)
- Blocking third-party libraries that don't cooperate with asyncio
- CLI workflows that require subprocess-level parallelism or safety
Example:
ProcessAction(
name="ComputeChecksum",
action=calculate_sha256,
args=("large_file.bin",),
)
Raises:
- `ValueError`: If an injected value is not pickleable
- `Exception`: Propagated from the subprocess on failure
This module enables structured offloading of workload in CLI pipelines while maintaining
full introspection and lifecycle management.
"""
from __future__ import annotations
import asyncio
from concurrent.futures import ProcessPoolExecutor
from functools import partial
from typing import Any, Callable
from rich.tree import Tree
from falyx.action.base_action import BaseAction
from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType
from falyx.themes import OneColors
class ProcessAction(BaseAction):
"""
ProcessAction runs a function in a separate process using ProcessPoolExecutor.
Features:
- Executes CPU-bound or blocking tasks without blocking the main event loop.
- Supports last_result injection into the subprocess.
- Validates that last_result is pickleable when injection is enabled.
Args:
name (str): Name of the action.
func (Callable): Function to execute in a new process.
args (tuple, optional): Positional arguments.
kwargs (dict, optional): Keyword arguments.
hooks (HookManager, optional): Hook manager for lifecycle events.
executor (ProcessPoolExecutor, optional): Custom executor if desired.
inject_last_result (bool, optional): Inject last result into the function.
inject_into (str, optional): Name of the injected key.
"""
def __init__(
self,
name: str,
action: Callable[..., Any],
*,
args: tuple = (),
kwargs: dict[str, Any] | None = None,
hooks: HookManager | None = None,
executor: ProcessPoolExecutor | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
never_prompt: bool | None = None,
logging_hooks: bool = False,
spinner: bool = False,
spinner_message: str = "Processing...",
spinner_type: str = "dots",
spinner_style: str = OneColors.CYAN,
spinner_speed: float = 1.0,
):
super().__init__(
name,
hooks=hooks,
inject_last_result=inject_last_result,
inject_into=inject_into,
never_prompt=never_prompt,
logging_hooks=logging_hooks,
spinner=spinner,
spinner_message=spinner_message,
spinner_type=spinner_type,
spinner_style=spinner_style,
spinner_speed=spinner_speed,
)
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})"
)

View File

@ -0,0 +1,235 @@
# 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})"
)

View File

@ -0,0 +1,191 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `PromptMenuAction`, a Falyx Action that prompts the user to choose from
a list of labeled options using a single-line prompt input. Each option corresponds
to a `MenuOption` that wraps a description and an executable action.
Unlike `MenuAction`, this action renders a flat, inline prompt (e.g., `Option1 | Option2`)
without using a rich table. It is ideal for compact decision points, hotkey-style menus,
or contextual user input flows.
Key Components:
- PromptMenuAction: Inline prompt-driven menu runner
"""
from typing import Any
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import FormattedText, merge_formatted_text
from rich.tree import Tree
from falyx.action.base_action import BaseAction
from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType
from falyx.logger import logger
from falyx.menu import MenuOptionMap
from falyx.prompt_utils import rich_text_to_prompt_text
from falyx.signals import BackSignal, CancelSignal, QuitSignal
from falyx.themes import OneColors
class PromptMenuAction(BaseAction):
"""
Displays a single-line interactive prompt for selecting an option from a menu.
`PromptMenuAction` is a lightweight alternative to `MenuAction`, offering a more
compact selection interface. Instead of rendering a full table, it displays
available keys inline as a placeholder (e.g., `A | B | C`) and accepts the user's
input to execute the associated action.
Each key is defined in a `MenuOptionMap`, which maps to a `MenuOption` containing
a description and an executable action.
Key Features:
- Minimal UI: rendered as a single prompt line with placeholder
- Optional fallback to `default_selection` or injected `last_result`
- Fully hookable lifecycle (before, success, error, after, teardown)
- Supports reserved keys and structured error recovery
Args:
name (str): Name of the action. Used for logging and debugging.
menu_options (MenuOptionMap): A mapping of keys to `MenuOption` objects.
prompt_message (str): Text displayed before user input (default: "Select > ").
default_selection (str): Fallback key if no input is provided.
inject_last_result (bool): Whether to use `last_result` as a fallback input key.
inject_into (str): Kwarg name under which to inject the last result.
prompt_session (PromptSession | None): Custom Prompt Toolkit session.
never_prompt (bool): If True, skips user input and uses `default_selection`.
include_reserved (bool): Whether to include reserved keys in logic and preview.
Returns:
Any: The result of the selected option's action.
Raises:
BackSignal: If the user signals to return to the previous menu.
QuitSignal: If the user signals to exit the CLI entirely.
ValueError: If `never_prompt` is enabled but no fallback is available.
Exception: If an error occurs during the action's execution.
Example:
PromptMenuAction(
name="HotkeyPrompt",
menu_options=MenuOptionMap(options={
"R": MenuOption("Run", ChainedAction(...)),
"S": MenuOption("Skip", Action(...)),
}),
prompt_message="Choose action > ",
)
"""
def __init__(
self,
name: str,
menu_options: MenuOptionMap,
*,
prompt_message: str = "Select > ",
default_selection: str = "",
inject_last_result: bool = False,
inject_into: str = "last_result",
prompt_session: PromptSession | None = None,
never_prompt: bool = False,
include_reserved: bool = True,
):
super().__init__(
name,
inject_last_result=inject_last_result,
inject_into=inject_into,
never_prompt=never_prompt,
)
self.menu_options = menu_options
self.prompt_message = rich_text_to_prompt_text(prompt_message)
self.default_selection = default_selection
self.prompt_session = prompt_session or PromptSession(
interrupt_exception=CancelSignal
)
self.include_reserved = include_reserved
def get_infer_target(self) -> tuple[None, None]:
return None, None
async def _run(self, *args, **kwargs) -> Any:
kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext(
name=self.name,
args=args,
kwargs=kwargs,
action=self,
)
effective_default = self.default_selection
maybe_result = str(self.last_result)
if maybe_result in self.menu_options:
effective_default = maybe_result
elif self.inject_last_result:
logger.warning(
"[%s] Injected last result '%s' not found in menu options",
self.name,
maybe_result,
)
if self.never_prompt and not effective_default:
raise ValueError(
f"[{self.name}] 'never_prompt' is True but no valid default_selection"
" was provided."
)
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
key = effective_default
if not self.never_prompt:
placeholder_formatted_text = []
for index, (key, option) in enumerate(self.menu_options.items()):
placeholder_formatted_text.append(option.render_prompt(key))
if index < len(self.menu_options) - 1:
placeholder_formatted_text.append(
FormattedText([(OneColors.WHITE, " | ")])
)
placeholder = merge_formatted_text(placeholder_formatted_text)
key = await self.prompt_session.prompt_async(
message=self.prompt_message, placeholder=placeholder
)
option = self.menu_options[key]
result = await option.action(*args, **kwargs)
context.result = result
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return result
except BackSignal:
logger.debug("[%s][BackSignal] ← Returning to previous menu", self.name)
return None
except QuitSignal:
logger.debug("[%s][QuitSignal] ← Exiting application", self.name)
raise
except Exception as error:
context.exception = error
await self.hooks.trigger(HookType.ON_ERROR, context)
raise
finally:
context.stop_timer()
await self.hooks.trigger(HookType.AFTER, context)
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
er.record(context)
async def preview(self, parent: Tree | None = None):
label = f"[{OneColors.LIGHT_YELLOW_b}]📋 PromptMenuAction[/] '{self.name}'"
tree = parent.add(label) if parent else Tree(label)
for key, option in self.menu_options.items():
tree.add(
f"[dim]{key}[/]: {option.description} → [italic]{option.action.name}[/]"
)
await option.action.preview(parent=tree)
if not parent:
self.console.print(tree)
def __str__(self) -> str:
return (
f"PromptMenuAction(name={self.name!r}, options={list(self.menu_options.keys())!r}, "
f"default_selection={self.default_selection!r}, "
f"include_reserved={self.include_reserved}, "
f"prompt={'off' if self.never_prompt else 'on'})"
)

View File

@ -0,0 +1,296 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `SaveFileAction`, a Falyx Action for writing structured or unstructured data
to a file in a variety of supported formats.
Supports overwrite control, automatic directory creation, and full lifecycle hook
integration. Compatible with chaining and injection of upstream results via
`inject_last_result`.
Supported formats: TEXT, JSON, YAML, TOML, CSV, TSV, XML
Key Features:
- Auto-serialization of Python data to structured formats
- Flexible path control with directory creation and overwrite handling
- Injection of data via chaining (`last_result`)
- Preview mode with file metadata visualization
Common use cases:
- Writing processed results to disk
- Logging artifacts from batch pipelines
- Exporting config or user input to JSON/YAML for reuse
"""
import csv
import json
import xml.etree.ElementTree as ET
from datetime import datetime
from pathlib import Path
from typing import Any, Literal
import toml
import yaml
from rich.tree import Tree
from falyx.action.action_types import FileType
from falyx.action.base_action import BaseAction
from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType
from falyx.logger import logger
from falyx.themes import OneColors
class SaveFileAction(BaseAction):
"""
Saves data to a file in the specified format.
`SaveFileAction` serializes and writes input data to disk using the format
defined by `file_type`. It supports plain text and structured formats like
JSON, YAML, TOML, CSV, TSV, and XML. Files may be overwritten or appended
based on settings, and parent directories are created if missing.
Data can be provided directly via the `data` argument or dynamically injected
from the previous Action using `inject_last_result`.
Key Features:
- Format-aware saving with validation
- Lifecycle hook support (before, success, error, after, teardown)
- Chain-compatible via last_result injection
- Supports safe overwrite behavior and preview diagnostics
Args:
name (str): Name of the action. Used for logging and debugging.
file_path (str | Path): Destination file path.
file_type (FileType | str): Output format (e.g., "json", "yaml", "text").
mode (Literal["w", "a"]): File mode—write or append. Default is "w".
encoding (str): Encoding to use when writing files (default: "UTF-8").
data (Any): Data to save. If omitted, uses last_result injection.
overwrite (bool): Whether to overwrite existing files. Default is True.
create_dirs (bool): Whether to auto-create parent directories.
inject_last_result (bool): Inject previous result as input if enabled.
inject_into (str): Name of kwarg to inject last_result into (default: "data").
Returns:
str: The full path to the saved file.
Raises:
FileExistsError: If the file exists and `overwrite` is False.
FileNotFoundError: If parent directory is missing and `create_dirs` is False.
ValueError: If data format is invalid for the target file type.
Exception: Any errors encountered during file writing.
Example:
SaveFileAction(
name="SaveOutput",
file_path="output/data.json",
file_type="json",
inject_last_result=True
)
"""
def __init__(
self,
name: str,
file_path: str,
file_type: FileType | str = FileType.TEXT,
mode: Literal["w", "a"] = "w",
encoding: str = "UTF-8",
data: Any = None,
overwrite: bool = True,
create_dirs: bool = True,
inject_last_result: bool = False,
inject_into: str = "data",
):
"""
SaveFileAction allows saving data to a file.
Args:
name (str): Name of the action.
file_path (str | Path): Path to the file where data will be saved.
file_type (FileType | str): Format to write to (e.g. TEXT, JSON, YAML).
mode (Literal["w", "a"]): File mode (default: "w").
encoding (str): Encoding to use when writing files (default: "UTF-8").
data (Any): Data to be saved (if not using inject_last_result).
overwrite (bool): Whether to overwrite the file if it exists.
create_dirs (bool): Whether to create parent directories if they do not exist.
inject_last_result (bool): Whether to inject result from previous action.
inject_into (str): Kwarg name to inject the last result as.
"""
super().__init__(
name=name, inject_last_result=inject_last_result, inject_into=inject_into
)
self._file_path = self._coerce_file_path(file_path)
self._file_type = FileType(file_type)
self.data = data
self.overwrite = overwrite
self.mode = mode
self.create_dirs = create_dirs
self.encoding = encoding
@property
def file_path(self) -> Path | None:
"""Get the file path as a Path object."""
return self._file_path
@file_path.setter
def file_path(self, value: str | Path):
"""Set the file path, converting to Path if necessary."""
self._file_path = self._coerce_file_path(value)
def _coerce_file_path(self, file_path: str | Path | None) -> Path | None:
"""Coerce the file path to a Path object."""
if isinstance(file_path, Path):
return file_path
elif isinstance(file_path, str):
return Path(file_path)
elif file_path is None:
return None
else:
raise TypeError("file_path must be a string or Path object")
@property
def file_type(self) -> FileType:
"""Get the file type."""
return self._file_type
def get_infer_target(self) -> tuple[None, None]:
return None, None
def _dict_to_xml(self, data: dict, root: ET.Element) -> None:
"""Convert a dictionary to XML format."""
for key, value in data.items():
if isinstance(value, dict):
sub_element = ET.SubElement(root, key)
self._dict_to_xml(value, sub_element)
elif isinstance(value, list):
for item in value:
item_element = ET.SubElement(root, key)
if isinstance(item, dict):
self._dict_to_xml(item, item_element)
else:
item_element.text = str(item)
else:
element = ET.SubElement(root, key)
element.text = str(value)
async def save_file(self, data: Any) -> None:
"""Save data to the specified file in the desired format."""
if self.file_path is None:
raise ValueError("file_path must be set before saving a file")
elif self.file_path.exists() and not self.overwrite:
raise FileExistsError(f"File already exists: {self.file_path}")
if self.file_path.parent and not self.file_path.parent.exists():
if self.create_dirs:
self.file_path.parent.mkdir(parents=True, exist_ok=True)
else:
raise FileNotFoundError(
f"Directory does not exist: {self.file_path.parent}"
)
try:
if self.file_type == FileType.TEXT:
self.file_path.write_text(data, encoding=self.encoding)
elif self.file_type == FileType.JSON:
self.file_path.write_text(
json.dumps(data, indent=4), encoding=self.encoding
)
elif self.file_type == FileType.TOML:
self.file_path.write_text(toml.dumps(data), encoding=self.encoding)
elif self.file_type == FileType.YAML:
self.file_path.write_text(yaml.dump(data), encoding=self.encoding)
elif self.file_type == FileType.CSV:
if not isinstance(data, list) or not all(
isinstance(row, list) for row in data
):
raise ValueError(
f"{self.file_type.name} file type requires a list of lists"
)
with open(
self.file_path, mode=self.mode, newline="", encoding=self.encoding
) as csvfile:
writer = csv.writer(csvfile)
writer.writerows(data)
elif self.file_type == FileType.TSV:
if not isinstance(data, list) or not all(
isinstance(row, list) for row in data
):
raise ValueError(
f"{self.file_type.name} file type requires a list of lists"
)
with open(
self.file_path, mode=self.mode, newline="", encoding=self.encoding
) as tsvfile:
writer = csv.writer(tsvfile, delimiter="\t")
writer.writerows(data)
elif self.file_type == FileType.XML:
if not isinstance(data, dict):
raise ValueError("XML file type requires data to be a dictionary")
root = ET.Element("root")
self._dict_to_xml(data, root)
tree = ET.ElementTree(root)
tree.write(self.file_path, encoding=self.encoding, xml_declaration=True)
else:
raise ValueError(f"Unsupported file type: {self.file_type}")
except Exception as error:
logger.error("Failed to save %s: %s", self.file_path.name, error)
raise
async def _run(self, *args, **kwargs):
combined_kwargs = self._maybe_inject_last_result(kwargs)
data = self.data or combined_kwargs.get(self.inject_into)
context = ExecutionContext(
name=self.name, args=args, kwargs=combined_kwargs, action=self
)
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
await self.save_file(data)
logger.debug("File saved successfully: %s", self.file_path)
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return str(self.file_path)
except Exception as error:
context.exception = error
await self.hooks.trigger(HookType.ON_ERROR, context)
raise
finally:
context.stop_timer()
await self.hooks.trigger(HookType.AFTER, context)
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
er.record(context)
async def preview(self, parent: Tree | None = None):
label = f"[{OneColors.CYAN}]💾 SaveFileAction[/] '{self.name}'"
tree = parent.add(label) if parent else Tree(label)
tree.add(f"[dim]Path:[/] {self.file_path}")
tree.add(f"[dim]Type:[/] {self.file_type.name}")
tree.add(f"[dim]Overwrite:[/] {self.overwrite}")
if self.file_path and self.file_path.exists():
if self.overwrite:
tree.add(f"[{OneColors.LIGHT_YELLOW}]⚠️ File will be overwritten[/]")
else:
tree.add(
f"[{OneColors.DARK_RED}]❌ File exists and overwrite is disabled[/]"
)
stat = self.file_path.stat()
tree.add(f"[dim]Size:[/] {stat.st_size:,} bytes")
tree.add(
f"[dim]Modified:[/] {datetime.fromtimestamp(stat.st_mtime):%Y-%m-%d %H:%M:%S}"
)
tree.add(
f"[dim]Created:[/] {datetime.fromtimestamp(stat.st_ctime):%Y-%m-%d %H:%M:%S}"
)
if not parent:
self.console.print(tree)
def __str__(self) -> str:
return f"SaveFileAction(file_path={self.file_path}, file_type={self.file_type})"

View File

@ -1,5 +1,47 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""select_file_action.py"""
"""
Defines `SelectFileAction`, a Falyx Action that allows users to select one or more
files from a target directory and optionally return either their content or path,
parsed based on a selected `FileType`.
This action combines rich interactive selection (via `SelectionOption`) with
format-aware parsing, making it ideal for loading external resources, injecting
config files, or dynamically selecting inputs mid-pipeline.
Supports filtering by file suffix, customizable prompt layout, multi-select mode,
and automatic content parsing for common formats.
Key Features:
- Lists files from a directory and renders them in a Rich-powered menu
- Supports suffix filtering (e.g., only `.yaml` or `.json` files)
- Returns content parsed as `str`, `dict`, `list`, or raw `Path` depending on `FileType`
- Works in single or multi-selection mode
- Fully compatible with Falyx hooks and context system
- Graceful cancellation via `CancelSignal`
Supported Return Types (`FileType`):
- `TEXT`: UTF-8 string content
- `PATH`: File path object (`Path`)
- `JSON`, `YAML`, `TOML`: Parsed dictionaries or lists
- `CSV`, `TSV`: `list[list[str]]` from structured rows
- `XML`: `ElementTree.Element` root object
Use Cases:
- Prompting users to select a config file during setup
- Dynamically loading data into chained workflows
- CLI interfaces that require structured file ingestion
Example:
SelectFileAction(
name="ChooseConfigFile",
directory="configs/",
suffix_filter=".yaml",
return_type="yaml",
)
This module is ideal for use cases where file choice is deferred to runtime
and needs to feed into structured automation pipelines.
"""
from __future__ import annotations
import csv
@ -11,26 +53,27 @@ from typing import Any
import toml
import yaml
from prompt_toolkit import PromptSession
from rich.console import Console
from rich.tree import Tree
from falyx.action.action import BaseAction
from falyx.action.types import FileReturnType
from falyx.action.action_types import FileType
from falyx.action.base_action import BaseAction
from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType
from falyx.logger import logger
from falyx.prompt_utils import rich_text_to_prompt_text
from falyx.selection import (
SelectionOption,
prompt_for_selection,
render_selection_dict_table,
)
from falyx.signals import CancelSignal
from falyx.themes import OneColors
class SelectFileAction(BaseAction):
"""
SelectFileAction allows users to select a file from a directory and return:
SelectFileAction allows users to select a file(s) from a directory and return:
- file content (as text, JSON, CSV, etc.)
- or the file path itself.
@ -49,8 +92,10 @@ class SelectFileAction(BaseAction):
prompt_message (str): Message to display when prompting for selection.
style (str): Style for the selection options.
suffix_filter (str | None): Restrict to certain file types.
return_type (FileReturnType): What to return (path, content, parsed).
console (Console | None): Console instance for output.
return_type (FileType): What to return (path, content, parsed).
number_selections (int | str): How many files to select (1, 2, '*').
separator (str): Separator for multiple selections.
allow_duplicates (bool): Allow selecting the same file multiple times.
prompt_session (PromptSession | None): Prompt session for user input.
"""
@ -64,62 +109,93 @@ class SelectFileAction(BaseAction):
prompt_message: str = "Choose > ",
style: str = OneColors.WHITE,
suffix_filter: str | None = None,
return_type: FileReturnType | str = FileReturnType.PATH,
console: Console | None = None,
return_type: FileType | str = FileType.PATH,
encoding: str = "UTF-8",
number_selections: int | str = 1,
separator: str = ",",
allow_duplicates: bool = False,
prompt_session: PromptSession | None = None,
):
super().__init__(name)
self.directory = Path(directory).resolve()
self.title = title
self.columns = columns
self.prompt_message = prompt_message
self.prompt_message = rich_text_to_prompt_text(prompt_message)
self.suffix_filter = suffix_filter
self.style = style
self.console = console or Console(color_system="auto")
self.prompt_session = prompt_session or PromptSession()
self.return_type = self._coerce_return_type(return_type)
self.number_selections = number_selections
self.separator = separator
self.allow_duplicates = allow_duplicates
self.prompt_session = prompt_session or PromptSession(
interrupt_exception=CancelSignal
)
self.return_type = FileType(return_type)
self.encoding = encoding
def _coerce_return_type(self, return_type: FileReturnType | str) -> FileReturnType:
if isinstance(return_type, FileReturnType):
return return_type
return FileReturnType(return_type)
@property
def number_selections(self) -> int | str:
return self._number_selections
@number_selections.setter
def number_selections(self, value: int | str):
if isinstance(value, int) and value > 0:
self._number_selections: int | str = value
elif isinstance(value, str):
if value not in ("*"):
raise ValueError("number_selections string must be one of '*'")
self._number_selections = value
else:
raise ValueError("number_selections must be a positive integer or one of '*'")
def get_options(self, files: list[Path]) -> dict[str, SelectionOption]:
value: Any
options = {}
for index, file in enumerate(files):
options[str(index)] = SelectionOption(
description=file.name,
value=file, # Store the Path only — parsing will happen later
style=self.style,
)
return options
def parse_file(self, file: Path) -> Any:
value: Any
try:
if self.return_type == FileReturnType.TEXT:
value = file.read_text(encoding="UTF-8")
elif self.return_type == FileReturnType.PATH:
if self.return_type == FileType.TEXT:
value = file.read_text(encoding=self.encoding)
elif self.return_type == FileType.PATH:
value = file
elif self.return_type == FileReturnType.JSON:
value = json.loads(file.read_text(encoding="UTF-8"))
elif self.return_type == FileReturnType.TOML:
value = toml.loads(file.read_text(encoding="UTF-8"))
elif self.return_type == FileReturnType.YAML:
value = yaml.safe_load(file.read_text(encoding="UTF-8"))
elif self.return_type == FileReturnType.CSV:
with open(file, newline="", encoding="UTF-8") as csvfile:
elif self.return_type == FileType.JSON:
value = json.loads(file.read_text(encoding=self.encoding))
elif self.return_type == FileType.TOML:
value = toml.loads(file.read_text(encoding=self.encoding))
elif self.return_type == FileType.YAML:
value = yaml.safe_load(file.read_text(encoding=self.encoding))
elif self.return_type == FileType.CSV:
with open(file, newline="", encoding=self.encoding) as csvfile:
reader = csv.reader(csvfile)
value = list(reader)
elif self.return_type == FileReturnType.TSV:
with open(file, newline="", encoding="UTF-8") as tsvfile:
elif self.return_type == FileType.TSV:
with open(file, newline="", encoding=self.encoding) as tsvfile:
reader = csv.reader(tsvfile, delimiter="\t")
value = list(reader)
elif self.return_type == FileReturnType.XML:
tree = ET.parse(file, parser=ET.XMLParser(encoding="UTF-8"))
root = tree.getroot()
value = ET.tostring(root, encoding="unicode")
elif self.return_type == FileType.XML:
tree = ET.parse(file, parser=ET.XMLParser(encoding=self.encoding))
value = tree.getroot()
else:
raise ValueError(f"Unsupported return type: {self.return_type}")
options[str(index)] = SelectionOption(
description=file.name, value=value, style=self.style
)
except Exception as error:
logger.warning("[ERROR] Failed to parse %s: %s", file.name, error)
return options
logger.error("Failed to parse %s: %s", file.name, error)
return value
def _find_cancel_key(self, options) -> str:
"""Return first numeric value not already used in the selection dict."""
for index in range(len(options)):
if str(index) not in options:
return str(index)
return str(len(options))
def get_infer_target(self) -> tuple[None, None]:
return None, None
async def _run(self, *args, **kwargs) -> Any:
context = ExecutionContext(name=self.name, args=args, kwargs=kwargs, action=self)
@ -127,30 +203,51 @@ class SelectFileAction(BaseAction):
try:
await self.hooks.trigger(HookType.BEFORE, context)
if not self.directory.exists():
raise FileNotFoundError(f"Directory {self.directory} does not exist.")
elif not self.directory.is_dir():
raise NotADirectoryError(f"{self.directory} is not a directory.")
files = [
f
for f in self.directory.iterdir()
if f.is_file()
and (self.suffix_filter is None or f.suffix == self.suffix_filter)
file
for file in self.directory.iterdir()
if file.is_file()
and (self.suffix_filter is None or file.suffix == self.suffix_filter)
]
if not files:
raise FileNotFoundError("No files found in directory.")
options = self.get_options(files)
cancel_key = self._find_cancel_key(options)
cancel_option = {
cancel_key: SelectionOption(
description="Cancel", value=CancelSignal(), style=OneColors.DARK_RED
)
}
table = render_selection_dict_table(
title=self.title, selections=options, columns=self.columns
title=self.title, selections=options | cancel_option, columns=self.columns
)
key = await prompt_for_selection(
options.keys(),
keys = await prompt_for_selection(
(options | cancel_option).keys(),
table,
console=self.console,
prompt_session=self.prompt_session,
prompt_message=self.prompt_message,
number_selections=self.number_selections,
separator=self.separator,
allow_duplicates=self.allow_duplicates,
cancel_key=cancel_key,
)
result = options[key].value
if isinstance(keys, str):
if keys == cancel_key:
raise CancelSignal("User canceled the selection.")
result = self.parse_file(options[keys].value)
elif isinstance(keys, list):
result = [self.parse_file(options[key].value) for key in keys]
context.result = result
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return result
@ -165,7 +262,7 @@ class SelectFileAction(BaseAction):
er.record(context)
async def preview(self, parent: Tree | None = None):
label = f"[{OneColors.GREEN}]📁 SelectFilesAction[/] '{self.name}'"
label = f"[{OneColors.GREEN}]📁 SelectFileAction[/] '{self.name}'"
tree = parent.add(label) if parent else Tree(label)
tree.add(f"[dim]Directory:[/] {str(self.directory)}")
@ -173,14 +270,15 @@ class SelectFileAction(BaseAction):
tree.add(f"[dim]Return type:[/] {self.return_type}")
tree.add(f"[dim]Prompt:[/] {self.prompt_message}")
tree.add(f"[dim]Columns:[/] {self.columns}")
tree.add("[dim]Loading:[/] Lazy (parsing occurs after selection)")
try:
files = list(self.directory.iterdir())
if self.suffix_filter:
files = [f for f in files if f.suffix == self.suffix_filter]
files = [file for file in files if file.suffix == self.suffix_filter]
sample = files[:10]
file_list = tree.add("[dim]Files:[/]")
for f in sample:
file_list.add(f"[dim]{f.name}[/]")
for file in sample:
file_list.add(f"[dim]{file.name}[/]")
if len(files) > 10:
file_list.add(f"[dim]... ({len(files) - 10} more)[/]")
except Exception as error:
@ -191,6 +289,6 @@ class SelectFileAction(BaseAction):
def __str__(self) -> str:
return (
f"SelectFilesAction(name={self.name!r}, dir={str(self.directory)!r}, "
f"SelectFileAction(name={self.name!r}, dir={str(self.directory)!r}, "
f"suffix_filter={self.suffix_filter!r}, return_type={self.return_type})"
)

View File

@ -1,49 +1,139 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""selection_action.py"""
"""
Defines `SelectionAction`, a highly flexible Falyx Action for interactive or headless
selection from a list or dictionary of user-defined options.
This module powers workflows that require prompting the user for input, selecting
configuration presets, branching execution paths, or collecting multiple values
in a type-safe, hook-compatible, and composable way.
Key Features:
- Supports both flat lists and structured dictionaries (`SelectionOptionMap`)
- Handles single or multi-selection with configurable separators
- Returns results in various formats (key, value, description, item, or mapping)
- Integrates fully with Falyx lifecycle hooks and `last_result` injection
- Works in interactive (`prompt_toolkit`) and non-interactive (headless) modes
- Renders a Rich-based table preview for diagnostics or dry runs
Usage Scenarios:
- Guided CLI wizards or configuration menus
- Dynamic branching or conditional step logic
- User-driven parameterization in chained workflows
- Reusable pickers for environments, files, datasets, etc.
Example:
SelectionAction(
name="ChooseMode",
selections={"dev": "Development", "prod": "Production"},
return_type="key"
)
This module is foundational to creating expressive, user-centered CLI experiences
within Falyx while preserving reproducibility and automation friendliness.
"""
from typing import Any
from prompt_toolkit import PromptSession
from rich.console import Console
from rich.tree import Tree
from falyx.action.action import BaseAction
from falyx.action.action_types import SelectionReturnType
from falyx.action.base_action import BaseAction
from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType
from falyx.logger import logger
from falyx.prompt_utils import rich_text_to_prompt_text
from falyx.selection import (
SelectionOption,
SelectionOptionMap,
prompt_for_index,
prompt_for_selection,
render_selection_dict_table,
render_selection_indexed_table,
)
from falyx.signals import CancelSignal
from falyx.themes import OneColors
from falyx.utils import CaseInsensitiveDict
class SelectionAction(BaseAction):
"""
A selection action that prompts the user to select an option from a list or
dictionary. The selected option is then returned as the result of the action.
A Falyx Action for interactively or programmatically selecting one or more items
from a list or dictionary of options.
If return_key is True, the key of the selected option is returned instead of
the value.
`SelectionAction` supports both `list[str]` and `dict[str, SelectionOption]`
inputs. It renders a prompt (unless `never_prompt=True`), validates user input
or injected defaults, and returns a structured result based on the specified
`return_type`.
It is commonly used for item pickers, confirmation flows, dynamic parameterization,
or guided workflows in interactive or headless CLI pipelines.
Features:
- Supports single or multiple selections (`number_selections`)
- Dictionary mode allows rich metadata (description, value, style)
- Flexible return values: key(s), value(s), item(s), description(s), or mappings
- Fully hookable lifecycle (`before`, `on_success`, `on_error`, `after`, `on_teardown`)
- Default selection logic supports previous results (`last_result`)
- Can run in headless mode using `never_prompt` and fallback defaults
Args:
name (str): Action name for tracking and logging.
selections (list[str] | dict[str, SelectionOption] | dict[str, Any]):
The available choices. If a plain dict is passed, values are converted
into `SelectionOption` instances.
title (str): Title shown in the selection UI (default: "Select an option").
columns (int): Number of columns in the selection table.
prompt_message (str): Input prompt for the user (default: "Select > ").
default_selection (str | list[str]): Key(s) or index(es) used as fallback selection.
number_selections (int | str): Max number of choices allowed (or "*" for unlimited).
separator (str): Character used to separate multi-selections (default: ",").
allow_duplicates (bool): Whether duplicate selections are allowed.
inject_last_result (bool): If True, attempts to inject the last result as default.
inject_into (str): The keyword name for injected value (default: "last_result").
return_type (SelectionReturnType | str): The type of result to return.
prompt_session (PromptSession | None): Reused or customized prompt_toolkit session.
never_prompt (bool): If True, skips prompting and uses default_selection or last_result.
show_table (bool): Whether to render the selection table before prompting.
Returns:
Any: The selected result(s), shaped according to `return_type`.
Raises:
CancelSignal: If the user chooses the cancel option.
ValueError: If configuration is invalid or no selection can be resolved.
TypeError: If `selections` is not a supported type.
Example:
SelectionAction(
name="PickEnv",
selections={"dev": "Development", "prod": "Production"},
return_type="key",
)
This Action supports use in both interactive menus and chained, non-interactive CLI flows.
"""
def __init__(
self,
name: str,
selections: list[str] | set[str] | tuple[str, ...] | dict[str, SelectionOption],
selections: (
list[str]
| set[str]
| tuple[str, ...]
| dict[str, SelectionOption]
| dict[str, Any]
),
*,
title: str = "Select an option",
columns: int = 5,
prompt_message: str = "Select > ",
default_selection: str = "",
default_selection: str | list[str] = "",
number_selections: int | str = 1,
separator: str = ",",
allow_duplicates: bool = False,
inject_last_result: bool = False,
inject_into: str = "last_result",
return_key: bool = False,
console: Console | None = None,
return_type: SelectionReturnType | str = "value",
prompt_session: PromptSession | None = None,
never_prompt: bool = False,
show_table: bool = True,
@ -55,18 +145,37 @@ class SelectionAction(BaseAction):
never_prompt=never_prompt,
)
# Setter normalizes to correct type, mypy can't infer that
self.selections: list[str] | CaseInsensitiveDict = selections # type: ignore[assignment]
self.return_key = return_key
self.selections: list[str] | SelectionOptionMap = selections # type: ignore[assignment]
self.return_type: SelectionReturnType = SelectionReturnType(return_type)
self.title = title
self.columns = columns
self.console = console or Console(color_system="auto")
self.prompt_session = prompt_session or PromptSession()
self.prompt_session = prompt_session or PromptSession(
interrupt_exception=CancelSignal
)
self.default_selection = default_selection
self.prompt_message = prompt_message
self.number_selections = number_selections
self.separator = separator
self.allow_duplicates = allow_duplicates
self.prompt_message = rich_text_to_prompt_text(prompt_message)
self.show_table = show_table
@property
def selections(self) -> list[str] | CaseInsensitiveDict:
def number_selections(self) -> int | str:
return self._number_selections
@number_selections.setter
def number_selections(self, value: int | str):
if isinstance(value, int) and value > 0:
self._number_selections: int | str = value
elif isinstance(value, str):
if value not in ("*"):
raise ValueError("number_selections string must be '*'")
self._number_selections = value
else:
raise ValueError("number_selections must be a positive integer or '*'")
@property
def selections(self) -> list[str] | SelectionOptionMap:
return self._selections
@selections.setter
@ -74,17 +183,190 @@ class SelectionAction(BaseAction):
self, value: list[str] | set[str] | tuple[str, ...] | dict[str, SelectionOption]
):
if isinstance(value, (list, tuple, set)):
self._selections: list[str] | CaseInsensitiveDict = list(value)
self._selections: list[str] | SelectionOptionMap = list(value)
elif isinstance(value, dict):
cid = CaseInsensitiveDict()
cid.update(value)
self._selections = cid
som = SelectionOptionMap()
if all(isinstance(key, str) for key in value) and all(
not isinstance(value[key], SelectionOption) for key in value
):
som.update(
{
str(index): SelectionOption(key, option)
for index, (key, option) in enumerate(value.items())
}
)
elif all(isinstance(key, str) for key in value) and all(
isinstance(value[key], SelectionOption) for key in value
):
som.update(value)
else:
raise ValueError("Invalid dictionary format. Keys must be strings")
self._selections = som
else:
raise TypeError(
"'selections' must be a list[str] or dict[str, SelectionOption], "
f"got {type(value).__name__}"
)
def _find_cancel_key(self) -> str:
"""Find the cancel key in the selections."""
if isinstance(self.selections, dict):
for index in range(len(self.selections) + 1):
if str(index) not in self.selections:
return str(index)
return str(len(self.selections))
@property
def cancel_key(self) -> str:
return self._cancel_key
@cancel_key.setter
def cancel_key(self, value: str) -> None:
"""Set the cancel key for the selection."""
if not isinstance(value, str):
raise TypeError("Cancel key must be a string.")
if isinstance(self.selections, dict) and value in self.selections:
raise ValueError(
"Cancel key cannot be one of the selection keys. "
f"Current selections: {self.selections}"
)
if isinstance(self.selections, list):
if not value.isdigit() or int(value) > len(self.selections):
raise ValueError(
"cancel_key must be a digit and not greater than the number of selections."
)
self._cancel_key = value
def cancel_formatter(self, index: int, selection: str) -> str:
"""Format the cancel option for display."""
if self.cancel_key == str(index):
return f"[{index}] [{OneColors.DARK_RED}]Cancel[/]"
return f"[{index}] {selection}"
def get_infer_target(self) -> tuple[None, None]:
return None, None
def _get_result_from_keys(self, keys: str | list[str]) -> Any:
if not isinstance(self.selections, dict):
raise TypeError("Selections must be a dictionary to get result by keys.")
if self.return_type == SelectionReturnType.KEY:
result: Any = keys
elif self.return_type == SelectionReturnType.VALUE:
if isinstance(keys, list):
result = [self.selections[key].value for key in keys]
elif isinstance(keys, str):
result = self.selections[keys].value
elif self.return_type == SelectionReturnType.ITEMS:
if isinstance(keys, list):
result = {key: self.selections[key] for key in keys}
elif isinstance(keys, str):
result = {keys: self.selections[keys]}
elif self.return_type == SelectionReturnType.DESCRIPTION:
if isinstance(keys, list):
result = [self.selections[key].description for key in keys]
elif isinstance(keys, str):
result = self.selections[keys].description
elif self.return_type == SelectionReturnType.DESCRIPTION_VALUE:
if isinstance(keys, list):
result = {
self.selections[key].description: self.selections[key].value
for key in keys
}
elif isinstance(keys, str):
result = {self.selections[keys].description: self.selections[keys].value}
else:
raise ValueError(f"Unsupported return type: {self.return_type}")
return result
async def _resolve_effective_default(self) -> str:
effective_default: str | list[str] = self.default_selection
maybe_result = self.last_result
if self.number_selections == 1:
if isinstance(effective_default, list):
effective_default = effective_default[0] if effective_default else ""
elif isinstance(maybe_result, list):
maybe_result = maybe_result[0] if maybe_result else ""
default = await self._resolve_single_default(maybe_result)
if not default:
default = await self._resolve_single_default(effective_default)
if not default and self.inject_last_result:
logger.warning(
"[%s] Injected last result '%s' not found in selections",
self.name,
maybe_result,
)
return default
if maybe_result and isinstance(maybe_result, list):
maybe_result = [
await self._resolve_single_default(item) for item in maybe_result
]
if (
maybe_result
and self.number_selections != "*"
and len(maybe_result) != self.number_selections
):
raise ValueError(
f"[{self.name}] 'number_selections' is {self.number_selections}, "
f"but last_result has a different length: {len(maybe_result)}."
)
return self.separator.join(maybe_result)
elif effective_default and isinstance(effective_default, list):
effective_default = [
await self._resolve_single_default(item) for item in effective_default
]
if (
effective_default
and self.number_selections != "*"
and len(effective_default) != self.number_selections
):
raise ValueError(
f"[{self.name}] 'number_selections' is {self.number_selections}, "
f"but default_selection has a different length: {len(effective_default)}."
)
return self.separator.join(effective_default)
if self.inject_last_result:
logger.warning(
"[%s] Injected last result '%s' not found in selections",
self.name,
maybe_result,
)
return ""
async def _resolve_single_default(self, maybe_result: str) -> str:
effective_default = ""
if isinstance(self.selections, dict):
if str(maybe_result) in self.selections:
effective_default = str(maybe_result)
elif maybe_result in (
selection.value for selection in self.selections.values()
):
selection = [
key
for key, sel in self.selections.items()
if sel.value == maybe_result
]
if selection:
effective_default = selection[0]
elif maybe_result in (
selection.description for selection in self.selections.values()
):
selection = [
key
for key, sel in self.selections.items()
if sel.description == maybe_result
]
if selection:
effective_default = selection[0]
elif isinstance(self.selections, list):
if str(maybe_result).isdigit() and int(maybe_result) in range(
len(self.selections)
):
effective_default = maybe_result
elif maybe_result in self.selections:
effective_default = str(self.selections.index(maybe_result))
return effective_default
async def _run(self, *args, **kwargs) -> Any:
kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext(
@ -94,77 +376,106 @@ class SelectionAction(BaseAction):
action=self,
)
effective_default = str(self.default_selection)
maybe_result = str(self.last_result)
if isinstance(self.selections, dict):
if maybe_result in self.selections:
effective_default = maybe_result
elif self.inject_last_result:
logger.warning(
"[%s] Injected last result '%s' not found in selections",
self.name,
maybe_result,
)
elif isinstance(self.selections, list):
if maybe_result.isdigit() and int(maybe_result) in range(
len(self.selections)
):
effective_default = maybe_result
elif self.inject_last_result:
logger.warning(
"[%s] Injected last result '%s' not found in selections",
self.name,
maybe_result,
)
effective_default = await self._resolve_effective_default()
if self.never_prompt and not effective_default:
raise ValueError(
f"[{self.name}] 'never_prompt' is True but no valid default_selection "
"was provided."
"or usable last_result was available."
)
context.start_timer()
try:
self.cancel_key = self._find_cancel_key()
await self.hooks.trigger(HookType.BEFORE, context)
if isinstance(self.selections, list):
table = render_selection_indexed_table(
title=self.title,
selections=self.selections,
selections=self.selections + ["Cancel"],
columns=self.columns,
formatter=self.cancel_formatter,
)
if effective_default is None or isinstance(effective_default, int):
effective_default = ""
if not self.never_prompt:
indices: int | list[int] = await prompt_for_index(
len(self.selections),
table,
default_selection=effective_default,
prompt_session=self.prompt_session,
prompt_message=self.prompt_message,
show_table=self.show_table,
number_selections=self.number_selections,
separator=self.separator,
allow_duplicates=self.allow_duplicates,
cancel_key=self.cancel_key,
)
else:
if effective_default and self.number_selections == 1:
indices = int(effective_default)
elif effective_default:
indices = [
int(index)
for index in effective_default.split(self.separator)
]
else:
raise ValueError(
f"[{self.name}] 'never_prompt' is True but no valid "
"default_selection was provided."
)
if indices == int(self.cancel_key):
raise CancelSignal("User cancelled the selection.")
if isinstance(indices, list):
result: str | list[str] = [
self.selections[index] for index in indices
]
elif isinstance(indices, int):
result = self.selections[indices]
else:
assert False, "unreachable"
elif isinstance(self.selections, dict):
cancel_option = {
self.cancel_key: SelectionOption(
description="Cancel", value=CancelSignal, style=OneColors.DARK_RED
)
}
table = render_selection_dict_table(
title=self.title,
selections=self.selections | cancel_option,
columns=self.columns,
)
if not self.never_prompt:
index = await prompt_for_index(
len(self.selections) - 1,
keys = await prompt_for_selection(
(self.selections | cancel_option).keys(),
table,
default_selection=effective_default,
console=self.console,
prompt_session=self.prompt_session,
prompt_message=self.prompt_message,
show_table=self.show_table,
number_selections=self.number_selections,
separator=self.separator,
allow_duplicates=self.allow_duplicates,
cancel_key=self.cancel_key,
)
else:
index = effective_default
result = self.selections[int(index)]
elif isinstance(self.selections, dict):
table = render_selection_dict_table(
title=self.title, selections=self.selections, columns=self.columns
)
if not self.never_prompt:
key = await prompt_for_selection(
self.selections.keys(),
table,
default_selection=effective_default,
console=self.console,
prompt_session=self.prompt_session,
prompt_message=self.prompt_message,
show_table=self.show_table,
)
if effective_default and self.number_selections == 1:
keys = effective_default
elif effective_default:
keys = effective_default.split(self.separator)
else:
key = effective_default
result = key if self.return_key else self.selections[key].value
raise ValueError(
f"[{self.name}] 'never_prompt' is True but no valid "
"default_selection was provided."
)
if keys == self.cancel_key:
raise CancelSignal("User cancelled the selection.")
result = self._get_result_from_keys(keys)
else:
raise TypeError(
"'selections' must be a list[str] or dict[str, tuple[str, Any]], "
"'selections' must be a list[str] or dict[str, Any], "
f"got {type(self.selections).__name__}"
)
context.result = result
@ -186,13 +497,13 @@ class SelectionAction(BaseAction):
if isinstance(self.selections, list):
sub = tree.add(f"[dim]Type:[/] List[str] ({len(self.selections)} items)")
for i, item in enumerate(self.selections[:10]): # limit to 10
for i, item in enumerate(self.selections[:10]):
sub.add(f"[dim]{i}[/]: {item}")
if len(self.selections) > 10:
sub.add(f"[dim]... ({len(self.selections) - 10} more)[/]")
elif isinstance(self.selections, dict):
sub = tree.add(
f"[dim]Type:[/] Dict[str, (str, Any)] ({len(self.selections)} items)"
f"[dim]Type:[/] Dict[str, SelectionOption] ({len(self.selections)} items)"
)
for i, (key, option) in enumerate(list(self.selections.items())[:10]):
sub.add(f"[dim]{key}[/]: {option.description}")
@ -202,9 +513,30 @@ class SelectionAction(BaseAction):
tree.add(f"[{OneColors.DARK_RED_b}]Invalid selections type[/]")
return
tree.add(f"[dim]Default:[/] '{self.default_selection or self.last_result}'")
tree.add(f"[dim]Return:[/] {'Key' if self.return_key else 'Value'}")
default = self.default_selection or self.last_result
if isinstance(default, list):
default_display = self.separator.join(str(d) for d in default)
else:
default_display = str(default or "")
tree.add(f"[dim]Default:[/] '{default_display}'")
return_behavior = {
"KEY": "selected key(s)",
"VALUE": "mapped value(s)",
"DESCRIPTION": "description(s)",
"ITEMS": "SelectionOption object(s)",
"DESCRIPTION_VALUE": "{description: value}",
}.get(self.return_type.name, self.return_type.name)
tree.add(
f"[dim]Return:[/] {self.return_type.name.capitalize()}{return_behavior}"
)
tree.add(f"[dim]Prompt:[/] {'Disabled' if self.never_prompt else 'Enabled'}")
tree.add(f"[dim]Columns:[/] {self.columns}")
tree.add(
f"[dim]Multi-select:[/] {'Yes' if self.number_selections != 1 else 'No'}"
)
if not parent:
self.console.print(tree)
@ -218,6 +550,6 @@ class SelectionAction(BaseAction):
return (
f"SelectionAction(name={self.name!r}, type={selection_type}, "
f"default_selection={self.default_selection!r}, "
f"return_key={self.return_key}, "
f"return_type={self.return_type!r}, "
f"prompt={'off' if self.never_prompt else 'on'})"
)

View File

@ -0,0 +1,104 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""Execute shell commands with input substitution."""
from __future__ import annotations
import shlex
import subprocess
import sys
from typing import Any, Callable
from rich.tree import Tree
from falyx.action.io_action import BaseIOAction
from falyx.exceptions import FalyxError
from falyx.themes import OneColors
class ShellAction(BaseIOAction):
"""
ShellAction wraps a shell command template for CLI pipelines.
This Action takes parsed input (from stdin, literal, or last_result),
substitutes it into the provided shell command template, and executes
the command asynchronously using subprocess.
Designed for quick integration with shell tools like `grep`, `ping`, `jq`, etc.
⚠️ Security Warning:
By default, ShellAction uses `shell=True`, which can be dangerous with
unsanitized input. To mitigate this, set `safe_mode=True` to use `shell=False`
with `shlex.split()`.
Features:
- Automatically handles input parsing (str/bytes)
- `safe_mode=True` disables shell interpretation and runs with `shell=False`
- Captures stdout and stderr from shell execution
- Raises on non-zero exit codes with stderr as the error
- Result is returned as trimmed stdout string
Args:
name (str): Name of the action.
command_template (str): Shell command to execute. Must include `{}` to include
input. If no placeholder is present, the input is not
included.
safe_mode (bool): If True, runs with `shell=False` using shlex parsing
(default: False).
"""
def __init__(
self, name: str, command_template: str, safe_mode: bool = False, **kwargs
):
super().__init__(name=name, **kwargs)
self.command_template = command_template
self.safe_mode = safe_mode
def from_input(self, raw: str | bytes) -> str:
if not isinstance(raw, (str, bytes)):
raise TypeError(
f"{self.name} expected str or bytes input, got {type(raw).__name__}"
)
return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip()
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
if sys.stdin.isatty():
return self._run, {"parsed_input": {"help": self.command_template}}
return None, None
async def _run(self, parsed_input: str) -> str:
# Replace placeholder in template, or use raw input as full command
command = self.command_template.format(parsed_input)
if self.safe_mode:
try:
args = shlex.split(command)
except ValueError as error:
raise FalyxError(f"Invalid command template: {error}")
result = subprocess.run(args, capture_output=True, text=True, check=True)
else:
result = subprocess.run(
command, shell=True, text=True, capture_output=True, check=True
)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip())
return result.stdout.strip()
def to_output(self, result: str) -> str:
return result
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.GREEN_b}]⚙ ShellAction[/] '{self.name}'"]
label.append(f"\n[dim]Template:[/] {self.command_template}")
label.append(
f"\n[dim]Safe mode:[/] {'Enabled' if self.safe_mode else 'Disabled'}"
)
if self.inject_last_result:
label.append(f" [dim](injects '{self.inject_into}')[/dim]")
tree = parent.add("".join(label)) if parent else Tree("".join(label))
if not parent:
self.console.print(tree)
def __str__(self):
return (
f"ShellAction(name={self.name!r}, command_template={self.command_template!r},"
f" safe_mode={self.safe_mode})"
)

View File

@ -1,32 +1,85 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""signal_action.py"""
"""
Defines `SignalAction`, a lightweight Falyx Action that raises a `FlowSignal`
(such as `BackSignal`, `QuitSignal`, or `BreakChainSignal`) during execution to
alter or exit the CLI flow.
Unlike traditional actions, `SignalAction` does not return a result—instead, it raises
a signal to break, back out, or exit gracefully. Despite its minimal behavior,
it fully supports Falyx's hook lifecycle, including `before`, `on_error`, `after`,
and `on_teardown`—allowing it to trigger logging, audit events, UI updates, or custom
telemetry before halting flow.
Key Features:
- Declaratively raises a `FlowSignal` from within any Falyx workflow
- Works in menus, chained actions, or conditionals
- Hook-compatible: can run pre- and post-signal lifecycle hooks
- Supports previewing and structured introspection
Use Cases:
- Implementing "Back", "Cancel", or "Quit" options in `MenuAction` or `PromptMenuAction`
- Triggering an intentional early exit from a `ChainedAction`
- Running cleanup hooks before stopping execution
Example:
SignalAction("ExitApp", QuitSignal(), hooks=my_hook_manager)
"""
from rich.tree import Tree
from falyx.action.action import Action
from falyx.hook_manager import HookManager
from falyx.signals import FlowSignal
from falyx.themes import OneColors
class SignalAction(Action):
"""
An action that raises a control flow signal when executed.
A hook-compatible action that raises a control flow signal when invoked.
Useful for exiting a menu, going back, or halting execution gracefully.
`SignalAction` raises a `FlowSignal` (e.g., `BackSignal`, `QuitSignal`,
`BreakChainSignal`) during execution. It is commonly used to exit menus,
break from chained actions, or halt workflows intentionally.
Even though the signal interrupts normal flow, all registered lifecycle hooks
(`before`, `on_error`, `after`, `on_teardown`) are triggered as expected—
allowing structured behavior such as logging, analytics, or UI changes
before the signal is raised.
Args:
name (str): Name of the action (used for logging and debugging).
signal (FlowSignal): A subclass of `FlowSignal` to raise (e.g., QuitSignal).
hooks (HookManager | None): Optional hook manager to attach lifecycle hooks.
Raises:
FlowSignal: Always raises the provided signal when the action is run.
"""
def __init__(self, name: str, signal: Exception):
def __init__(self, name: str, signal: FlowSignal, hooks: HookManager | None = None):
self.signal = signal
super().__init__(name, action=self.raise_signal)
super().__init__(name, action=self.raise_signal, hooks=hooks)
async def raise_signal(self, *args, **kwargs):
"""
Raises the configured `FlowSignal`.
This method is called internally by the Falyx runtime and is the core
behavior of the action. All hooks surrounding execution are still triggered.
"""
raise self.signal
@property
def signal(self):
"""Returns the configured `FlowSignal` instance."""
return self._signal
@signal.setter
def signal(self, value: FlowSignal):
"""
Validates that the provided value is a `FlowSignal`.
Raises:
TypeError: If `value` is not an instance of `FlowSignal`.
"""
if not isinstance(value, FlowSignal):
raise TypeError(
f"Signal must be an FlowSignal instance, got {type(value).__name__}"

View File

@ -1,37 +0,0 @@
from __future__ import annotations
from enum import Enum
class FileReturnType(Enum):
"""Enum for file return types."""
TEXT = "text"
PATH = "path"
JSON = "json"
TOML = "toml"
YAML = "yaml"
CSV = "csv"
TSV = "tsv"
XML = "xml"
@classmethod
def _get_alias(cls, value: str) -> str:
aliases = {
"yml": "yaml",
"txt": "text",
"file": "path",
"filepath": "path",
}
return aliases.get(value, value)
@classmethod
def _missing_(cls, value: object) -> FileReturnType:
if isinstance(value, str):
normalized = value.lower()
alias = cls._get_alias(normalized)
for member in cls:
if member.value == alias:
return member
valid = ", ".join(member.value for member in cls)
raise ValueError(f"Invalid FileReturnType: '{value}'. Must be one of: {valid}")

View File

@ -1,36 +1,70 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `UserInputAction`, a Falyx Action that prompts the user for input using
Prompt Toolkit and returns the result as a string.
This action is ideal for interactive CLI workflows that require user input mid-pipeline.
It supports dynamic prompt interpolation, prompt validation, default text fallback,
and full lifecycle hook execution.
Key Features:
- Rich Prompt Toolkit integration for input and validation
- Dynamic prompt formatting using `last_result` injection
- Optional `Validator` support for structured input (e.g., emails, numbers)
- Hook lifecycle compatibility (before, on_success, on_error, after, teardown)
- Preview support for introspection or dry-run flows
Use Cases:
- Asking for confirmation text or field input mid-chain
- Injecting user-provided variables into automated pipelines
- Interactive menu or wizard experiences
Example:
UserInputAction(
name="GetUsername",
prompt_message="Enter your username > ",
validator=Validator.from_callable(lambda s: len(s) > 0),
)
"""
from prompt_toolkit import PromptSession
from prompt_toolkit.validation import Validator
from rich.console import Console
from rich.tree import Tree
from falyx.action import BaseAction
from falyx.action.base_action import BaseAction
from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType
from falyx.prompt_utils import rich_text_to_prompt_text
from falyx.signals import CancelSignal
from falyx.themes.colors import OneColors
class UserInputAction(BaseAction):
"""
Prompts the user for input via PromptSession and returns the result.
Prompts the user for textual input and returns their response.
`UserInputAction` uses Prompt Toolkit to gather input with optional validation,
lifecycle hook compatibility, and support for default text. If `inject_last_result`
is enabled, the prompt message can interpolate `{last_result}` dynamically.
Args:
name (str): Action name.
prompt_text (str): Prompt text (can include '{last_result}' for interpolation).
validator (Validator, optional): Prompt Toolkit validator.
console (Console, optional): Rich console for rendering.
prompt_session (PromptSession, optional): Reusable prompt session.
inject_last_result (bool): Whether to inject last_result into prompt.
inject_into (str): Key to use for injection (default: 'last_result').
name (str): Name of the action (used for introspection and logging).
prompt_message (str): The prompt message shown to the user.
Can include `{last_result}` if `inject_last_result=True`.
default_text (str): Optional default value shown in the prompt.
validator (Validator | None): Prompt Toolkit validator for input constraints.
prompt_session (PromptSession | None): Optional custom prompt session.
inject_last_result (bool): Whether to inject `last_result` into the prompt.
"""
def __init__(
self,
name: str,
*,
prompt_text: str = "Input > ",
prompt_message: str = "Input > ",
default_text: str = "",
multiline: bool = False,
validator: Validator | None = None,
console: Console | None = None,
prompt_session: PromptSession | None = None,
inject_last_result: bool = False,
):
@ -38,10 +72,16 @@ class UserInputAction(BaseAction):
name=name,
inject_last_result=inject_last_result,
)
self.prompt_text = prompt_text
self.prompt_message = prompt_message
self.default_text = default_text
self.multiline = multiline
self.validator = validator
self.console = console or Console(color_system="auto")
self.prompt_session = prompt_session or PromptSession()
self.prompt_session = prompt_session or PromptSession(
interrupt_exception=CancelSignal
)
def get_infer_target(self) -> tuple[None, None]:
return None, None
async def _run(self, *args, **kwargs) -> str:
context = ExecutionContext(
@ -54,13 +94,15 @@ class UserInputAction(BaseAction):
try:
await self.hooks.trigger(HookType.BEFORE, context)
prompt_text = self.prompt_text
prompt_message = self.prompt_message
if self.inject_last_result and self.last_result:
prompt_text = prompt_text.format(last_result=self.last_result)
prompt_message = prompt_message.format(last_result=self.last_result)
answer = await self.prompt_session.prompt_async(
prompt_text,
rich_text_to_prompt_text(prompt_message),
validator=self.validator,
default=kwargs.get("default_text", self.default_text),
multiline=self.multiline,
)
context.result = answer
await self.hooks.trigger(HookType.ON_SUCCESS, context)
@ -79,12 +121,12 @@ class UserInputAction(BaseAction):
label = f"[{OneColors.MAGENTA}]⌨ UserInputAction[/] '{self.name}'"
tree = parent.add(label) if parent else Tree(label)
prompt_text = (
self.prompt_text.replace("{last_result}", "<last_result>")
if "{last_result}" in self.prompt_text
else self.prompt_text
prompt_message = (
self.prompt_message.replace("{last_result}", "<last_result>")
if "{last_result}" in self.prompt_message
else self.prompt_message
)
tree.add(f"[dim]Prompt:[/] {prompt_text}")
tree.add(f"[dim]Prompt:[/] {prompt_message}")
if self.validator:
tree.add("[dim]Validator:[/] Yes")
if not parent:

View File

@ -1,12 +1,45 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""bottom_bar.py"""
"""
Provides the `BottomBar` class for managing a customizable bottom status bar in
Falyx-based CLI applications.
The bottom bar is rendered using `prompt_toolkit` and supports:
- Rich-formatted static content
- Live-updating value trackers and counters
- Toggle switches activated via Ctrl+<key> bindings
- Config-driven visual and behavioral controls
Each item in the bar is registered by name and rendered in columns across the
bottom of the terminal. Toggles are linked to user-defined state accessors and
mutators, and can be automatically bound to `OptionsManager` values for full
integration with Falyx CLI argument parsing.
Key Features:
- Live rendering of structured status items using Rich-style HTML
- Custom or built-in item types: static text, dynamic counters, toggles, value displays
- Ctrl+key toggle handling via `prompt_toolkit.KeyBindings`
- Columnar layout with automatic width scaling
- Optional integration with `OptionsManager` for dynamic state toggling
Usage Example:
bar = BottomBar(columns=3)
bar.add_static("env", "ENV: dev")
bar.add_toggle("d", "Debug", get_debug, toggle_debug)
bar.add_value_tracker("attempts", "Retries", get_retry_count)
bar.render()
Used by Falyx to provide a persistent UI element showing toggles, system state,
and runtime telemetry below the input prompt.
"""
from typing import Any, Callable
from prompt_toolkit.formatted_text import HTML, merge_formatted_text
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
from rich.console import Console
from falyx.console import console
from falyx.options_manager import OptionsManager
from falyx.themes import OneColors
from falyx.utils import CaseInsensitiveDict, chunks
@ -23,19 +56,19 @@ class BottomBar:
Must return True if key is available, otherwise False.
"""
RESERVED_CTRL_KEYS = {"c", "d", "z", "v"}
def __init__(
self,
columns: int = 3,
key_bindings: KeyBindings | None = None,
key_validator: Callable[[str], bool] | None = None,
) -> None:
self.columns = columns
self.console = Console(color_system="auto")
self.console: Console = console
self._named_items: dict[str, Callable[[], HTML]] = {}
self._value_getters: dict[str, Callable[[], Any]] = CaseInsensitiveDict()
self.toggle_keys: list[str] = []
self.key_bindings = key_bindings or KeyBindings()
self.key_validator = key_validator
@staticmethod
def default_render(label: str, value: Any, fg: str, bg: str, width: int) -> HTML:
@ -120,17 +153,31 @@ class BottomBar:
bg_on: str = OneColors.GREEN,
bg_off: str = OneColors.DARK_RED,
) -> None:
"""
Add a toggle to the bottom bar.
Always uses the ctrl + key combination for toggling.
Args:
key (str): The key to toggle the state.
label (str): The label for the toggle.
get_state (Callable[[], bool]): Function to get the current state.
toggle_state (Callable[[], None]): Function to toggle the state.
fg (str): Foreground color for the label.
bg_on (str): Background color when the toggle is ON.
bg_off (str): Background color when the toggle is OFF.
"""
key = key.lower()
if key in self.RESERVED_CTRL_KEYS:
raise ValueError(
f"'{key}' is a reserved terminal control key and cannot be used for toggles."
)
if not callable(get_state):
raise ValueError("`get_state` must be a callable returning bool")
if not callable(toggle_state):
raise ValueError("`toggle_state` must be a callable")
key = key.upper()
if key in self.toggle_keys:
raise ValueError(f"Key {key} is already used as a toggle")
if self.key_validator and not self.key_validator(key):
raise ValueError(
f"Key '{key}' conflicts with existing command, toggle, or reserved key."
)
self._value_getters[key] = get_state
self.toggle_keys.append(key)
@ -138,15 +185,13 @@ class BottomBar:
get_state_ = self._value_getters[key]
color = bg_on if get_state_() else bg_off
status = "ON" if get_state_() else "OFF"
text = f"({key.upper()}) {label}: {status}"
text = f"(^{key.lower()}) {label}: {status}"
return HTML(f"<style bg='{color}' fg='{fg}'>{text:^{self.space}}</style>")
self._add_named(key, render)
for k in (key.upper(), key.lower()):
@self.key_bindings.add(k)
def _(_):
@self.key_bindings.add(f"c-{key.lower()}", eager=True)
def _(_: KeyPressEvent):
toggle_state()
def add_toggle_from_option(

View File

@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""command.py
"""
Defines the Command class for Falyx CLI.
Commands are callable units representing a menu option or CLI task,
@ -19,33 +18,24 @@ in building robust interactive menus.
from __future__ import annotations
import shlex
from functools import cached_property
from typing import Any, Callable
from typing import Any, Awaitable, Callable
from prompt_toolkit.formatted_text import FormattedText
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
from rich.console import Console
from rich.tree import Tree
from falyx.action.action import (
Action,
ActionGroup,
BaseAction,
ChainedAction,
ProcessAction,
)
from falyx.action.io_action import BaseIOAction
from falyx.action.action import Action
from falyx.action.base_action import BaseAction
from falyx.console import console
from falyx.context import ExecutionContext
from falyx.debug import register_debug_hooks
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType
from falyx.logger import logger
from falyx.mode import FalyxMode
from falyx.options_manager import OptionsManager
from falyx.parsers import (
CommandArgumentParser,
infer_args_from_func,
same_argument_definitions,
)
from falyx.parser.command_argument_parser import CommandArgumentParser
from falyx.parser.signature import infer_args_from_func
from falyx.prompt_utils import confirm_async, should_prompt_user
from falyx.protocols import ArgParserProtocol
from falyx.retry import RetryPolicy
@ -54,8 +44,6 @@ from falyx.signals import CancelSignal
from falyx.themes import OneColors
from falyx.utils import ensure_async
console = Console(color_system="auto")
class Command(BaseModel):
"""
@ -92,19 +80,26 @@ class Command(BaseModel):
spinner_message (str): Spinner text message.
spinner_type (str): Spinner style (e.g., dots, line, etc.).
spinner_style (str): Color or style of the spinner.
spinner_kwargs (dict): Extra spinner configuration.
spinner_speed (float): Speed of the spinner animation.
hooks (HookManager): Hook manager for lifecycle events.
retry (bool): Enable retry on failure.
retry_all (bool): Enable retry across chained or grouped actions.
retry_policy (RetryPolicy): Retry behavior configuration.
tags (list[str]): Organizational tags for the command.
logging_hooks (bool): Whether to attach logging hooks automatically.
requires_input (bool | None): Indicates if the action needs input.
options_manager (OptionsManager): Manages global command-line options.
arg_parser (CommandArgumentParser): Parses command arguments.
arguments (list[dict[str, Any]]): Argument definitions for the command.
argument_config (Callable[[CommandArgumentParser], None] | None): Function to configure arguments
for the command parser.
custom_parser (ArgParserProtocol | None): Custom argument parser.
custom_help (Callable[[], str | None] | None): Custom help message generator.
auto_args (bool): Automatically infer arguments from the action.
arg_metadata (dict[str, str | dict[str, Any]]): Metadata for arguments,
such as help text or choices.
simple_help_signature (bool): Whether to use a simplified help signature.
ignore_in_history (bool): Whether to ignore this command in execution history last result.
program: (str | None): The parent program name.
Methods:
__call__(): Executes the command, respecting hooks and retries.
@ -116,13 +111,13 @@ class Command(BaseModel):
key: str
description: str
action: BaseAction | Callable[[Any], Any]
action: BaseAction | Callable[..., Any] | Callable[..., Awaitable[Any]]
args: tuple = ()
kwargs: dict[str, Any] = Field(default_factory=dict)
hidden: bool = False
aliases: list[str] = Field(default_factory=list)
help_text: str = ""
help_epilogue: str = ""
help_epilog: str = ""
style: str = OneColors.WHITE
confirm: bool = False
confirm_message: str = "Are you sure?"
@ -131,31 +126,33 @@ class Command(BaseModel):
spinner_message: str = "Processing..."
spinner_type: str = "dots"
spinner_style: str = OneColors.CYAN
spinner_kwargs: dict[str, Any] = Field(default_factory=dict)
spinner_speed: float = 1.0
hooks: "HookManager" = Field(default_factory=HookManager)
retry: bool = False
retry_all: bool = False
retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
tags: list[str] = Field(default_factory=list)
logging_hooks: bool = False
requires_input: bool | None = None
options_manager: OptionsManager = Field(default_factory=OptionsManager)
arg_parser: CommandArgumentParser = Field(default_factory=CommandArgumentParser)
arg_parser: CommandArgumentParser | None = None
arguments: list[dict[str, Any]] = Field(default_factory=list)
argument_config: Callable[[CommandArgumentParser], None] | None = None
custom_parser: ArgParserProtocol | None = None
custom_help: Callable[[], str | None] | None = None
auto_args: bool = False
auto_args: bool = True
arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict)
simple_help_signature: bool = False
ignore_in_history: bool = False
program: str | None = None
_context: ExecutionContext | None = PrivateAttr(default=None)
model_config = ConfigDict(arbitrary_types_allowed=True)
def parse_args(
async def parse_args(
self, raw_args: list[str] | str, from_validate: bool = False
) -> tuple[tuple, dict]:
if self.custom_parser:
if callable(self.custom_parser):
if isinstance(raw_args, str):
try:
raw_args = shlex.split(raw_args)
@ -178,7 +175,15 @@ class Command(BaseModel):
raw_args,
)
return ((), {})
return self.arg_parser.parse_args_split(raw_args, from_validate=from_validate)
if not isinstance(self.arg_parser, CommandArgumentParser):
logger.warning(
"[Command:%s] No argument parser configured, using default parsing.",
self.key,
)
return ((), {})
return await self.arg_parser.parse_args_split(
raw_args, from_validate=from_validate
)
@field_validator("action", mode="before")
@classmethod
@ -192,28 +197,17 @@ class Command(BaseModel):
def get_argument_definitions(self) -> list[dict[str, Any]]:
if self.arguments:
return self.arguments
elif self.argument_config:
elif callable(self.argument_config) and isinstance(
self.arg_parser, CommandArgumentParser
):
self.argument_config(self.arg_parser)
elif self.auto_args:
if isinstance(self.action, (Action, ProcessAction)):
return infer_args_from_func(self.action.action, self.arg_metadata)
elif isinstance(self.action, ChainedAction):
if self.action.actions:
action = self.action.actions[0]
if isinstance(action, Action):
return infer_args_from_func(action.action, self.arg_metadata)
elif callable(action):
return infer_args_from_func(action, self.arg_metadata)
elif isinstance(self.action, ActionGroup):
arg_defs = same_argument_definitions(
self.action.actions, self.arg_metadata
)
if arg_defs:
return arg_defs
logger.debug(
"[Command:%s] auto_args disabled: mismatched ActionGroup arguments",
self.key,
)
if isinstance(self.action, BaseAction):
infer_target, maybe_metadata = self.action.get_infer_target()
# merge metadata with the action's metadata if not already in self.arg_metadata
if maybe_metadata:
self.arg_metadata = {**maybe_metadata, **self.arg_metadata}
return infer_args_from_func(infer_target, self.arg_metadata)
elif callable(self.action):
return infer_args_from_func(self.action, self.arg_metadata)
return []
@ -241,29 +235,22 @@ class Command(BaseModel):
if self.logging_hooks and isinstance(self.action, BaseAction):
register_debug_hooks(self.action.hooks)
if self.requires_input is None and self.detect_requires_input:
self.requires_input = True
self.hidden = True
elif self.requires_input is None:
self.requires_input = False
if self.arg_parser is None and not self.custom_parser:
self.arg_parser = CommandArgumentParser(
command_key=self.key,
command_description=self.description,
command_style=self.style,
help_text=self.help_text,
help_epilog=self.help_epilog,
aliases=self.aliases,
program=self.program,
options_manager=self.options_manager,
)
for arg_def in self.get_argument_definitions():
self.arg_parser.add_argument(*arg_def.pop("flags"), **arg_def)
@cached_property
def detect_requires_input(self) -> bool:
"""Detect if the action requires input based on its type."""
if isinstance(self.action, BaseIOAction):
return True
elif isinstance(self.action, ChainedAction):
return (
isinstance(self.action.actions[0], BaseIOAction)
if self.action.actions
else False
)
elif isinstance(self.action, ActionGroup):
return any(isinstance(action, BaseIOAction) for action in self.action.actions)
return False
if self.ignore_in_history and isinstance(self.action, BaseAction):
self.action.ignore_in_history = True
def _inject_options_manager(self) -> None:
"""Inject the options manager into the action if applicable."""
@ -290,22 +277,13 @@ class Command(BaseModel):
if self.preview_before_confirm:
await self.preview()
if not await confirm_async(self.confirmation_prompt):
logger.info("[Command:%s] Cancelled by user.", self.key)
logger.info("[Command:%s] Cancelled by user.", self.key)
raise CancelSignal(f"[Command:{self.key}] Cancelled by confirmation.")
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
if self.spinner:
with console.status(
self.spinner_message,
spinner=self.spinner_type,
spinner_style=self.spinner_style,
**self.spinner_kwargs,
):
result = await self.action(*combined_args, **combined_kwargs)
else:
result = await self.action(*combined_args, **combined_kwargs)
context.result = result
@ -351,13 +329,53 @@ class Command(BaseModel):
return FormattedText(prompt)
@property
def usage(self) -> str:
"""Generate a help string for the command arguments."""
if not self.arg_parser:
return "No arguments defined."
command_keys_text = self.arg_parser.get_command_keys_text(plain_text=True)
options_text = self.arg_parser.get_options_text(plain_text=True)
return f" {command_keys_text:<20} {options_text} "
@property
def help_signature(self) -> tuple[str, str, str]:
"""Generate a help signature for the command."""
is_cli_mode = self.options_manager.get("mode") in {
FalyxMode.RUN,
FalyxMode.PREVIEW,
FalyxMode.RUN_ALL,
}
program = f"{self.program} run " if is_cli_mode else ""
if self.arg_parser and not self.simple_help_signature:
usage = f"[{self.style}]{program}[/]{self.arg_parser.get_usage()}"
description = f"[dim]{self.help_text or self.description}[/dim]"
if self.tags:
tags = f"[dim]Tags: {', '.join(self.tags)}[/dim]"
else:
tags = ""
return usage, description, tags
command_keys = " | ".join(
[f"[{self.style}]{self.key}[/{self.style}]"]
+ [f"[{self.style}]{alias}[/{self.style}]" for alias in self.aliases]
)
return (
f"[{self.style}]{program}[/]{command_keys}",
f"[dim]{self.description}[/dim]",
"",
)
def log_summary(self) -> None:
if self._context:
self._context.log_summary()
def show_help(self) -> bool:
def render_help(self) -> bool:
"""Display the help message for the command."""
if self.custom_help:
if callable(self.custom_help):
output = self.custom_help()
if output:
console.print(output)

128
falyx/completer.py Normal file
View File

@ -0,0 +1,128 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Provides `FalyxCompleter`, an intelligent autocompletion engine for Falyx CLI
menus using Prompt Toolkit.
This completer supports:
- Command key and alias completion (e.g. `R`, `HELP`, `X`)
- Argument flag completion for registered commands (e.g. `--tag`, `--name`)
- Context-aware suggestions based on cursor position and argument structure
- Interactive value completions (e.g. choices and suggestions defined per argument)
Completions are sourced from `CommandArgumentParser.suggest_next`, which analyzes
parsed tokens to determine appropriate next arguments, flags, or values.
Integrated with the `Falyx.prompt_session` to enhance the interactive experience.
"""
from __future__ import annotations
import shlex
from typing import TYPE_CHECKING, Iterable
from prompt_toolkit.completion import Completer, Completion
from prompt_toolkit.document import Document
if TYPE_CHECKING:
from falyx import Falyx
class FalyxCompleter(Completer):
"""
Prompt Toolkit completer for Falyx CLI command input.
This completer provides real-time, context-aware suggestions for:
- Command keys and aliases (resolved via Falyx._name_map)
- CLI argument flags and values for each command
- Suggestions and choices defined in the associated CommandArgumentParser
It leverages `CommandArgumentParser.suggest_next()` to compute valid completions
based on current argument state, including:
- Remaining required or optional flags
- Flag value suggestions (choices or custom completions)
- Next positional argument hints
Args:
falyx (Falyx): The Falyx menu instance containing all command mappings and parsers.
"""
def __init__(self, falyx: "Falyx"):
self.falyx = falyx
def get_completions(self, document: Document, complete_event) -> Iterable[Completion]:
"""
Yield completions based on the current document input.
Args:
document (Document): The prompt_toolkit document containing the input buffer.
complete_event: The completion trigger event (unused).
Yields:
Completion objects matching command keys or argument suggestions.
"""
text = document.text_before_cursor
try:
tokens = shlex.split(text)
cursor_at_end_of_token = document.text_before_cursor.endswith((" ", "\t"))
except ValueError:
return
if not tokens or (len(tokens) == 1 and not cursor_at_end_of_token):
# Suggest command keys and aliases
yield from self._suggest_commands(tokens[0] if tokens else "")
return
# Identify command
command_key = tokens[0].upper()
command = self.falyx._name_map.get(command_key)
if not command or not command.arg_parser:
return
# If at end of token, e.g., "--t" vs "--tag ", add a stub so suggest_next sees it
parsed_args = tokens[1:] if cursor_at_end_of_token else tokens[1:-1]
stub = "" if cursor_at_end_of_token else tokens[-1]
try:
if not command.arg_parser:
return
suggestions = command.arg_parser.suggest_next(
parsed_args + ([stub] if stub else []), cursor_at_end_of_token
)
for suggestion in suggestions:
if suggestion.startswith(stub):
if len(suggestion.split()) > 1:
yield Completion(
f'"{suggestion}"',
start_position=-len(stub),
display=suggestion,
)
else:
yield Completion(suggestion, start_position=-len(stub))
except Exception:
return
def _suggest_commands(self, prefix: str) -> Iterable[Completion]:
"""
Suggest top-level command keys and aliases based on the given prefix.
Args:
prefix (str): The user input to match against available commands.
Yields:
Completion: Matching keys or aliases from all registered commands.
"""
prefix = prefix.upper()
keys = [self.falyx.exit_command.key]
keys.extend(self.falyx.exit_command.aliases)
if self.falyx.history_command:
keys.append(self.falyx.history_command.key)
keys.extend(self.falyx.history_command.aliases)
if self.falyx.help_command:
keys.append(self.falyx.help_command.key)
keys.extend(self.falyx.help_command.aliases)
for cmd in self.falyx.commands.values():
keys.append(cmd.key)
keys.extend(cmd.aliases)
for key in keys:
if key.upper().startswith(prefix):
yield Completion(key, start_position=-len(prefix))

View File

@ -1,6 +1,41 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""config.py
Configuration loader for Falyx CLI commands."""
"""
Configuration loader and schema definitions for the Falyx CLI framework.
This module supports config-driven initialization of CLI commands and submenus
from YAML or TOML files. It enables declarative command definitions, auto-imports
Python callables from dotted paths, and wraps them in `Action` or `Command` objects
as needed.
Features:
- Parses Falyx command and submenu definitions from YAML or TOML.
- Supports hooks, retry policies, confirm prompts, spinners, aliases, and tags.
- Dynamically imports Python functions/classes from `action:` strings.
- Wraps user callables into Falyx `Command` or `Action` instances.
- Validates prompt and retry configuration using `pydantic` models.
Main Components:
- `FalyxConfig`: Pydantic model for top-level config structure.
- `RawCommand`: Intermediate command definition model from raw config.
- `Submenu`: Schema for nested CLI menus.
- `loader(path)`: Loads and returns a fully constructed `Falyx` instance.
Typical Config (YAML):
```yaml
title: My CLI
commands:
- key: A
description: Say hello
action: my_package.tasks.hello
aliases: [hi]
tags: [example]
```
Example:
from falyx.config import loader
cli = loader("falyx.yaml")
cli.run()
"""
from __future__ import annotations
import importlib
@ -11,17 +46,16 @@ from typing import Any, Callable
import toml
import yaml
from pydantic import BaseModel, Field, field_validator, model_validator
from rich.console import Console
from falyx.action.action import Action, BaseAction
from falyx.action.action import Action
from falyx.action.base_action import BaseAction
from falyx.command import Command
from falyx.console import console
from falyx.falyx import Falyx
from falyx.logger import logger
from falyx.retry import RetryPolicy
from falyx.themes import OneColors
console = Console(color_system="auto")
def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command:
if isinstance(obj, (BaseAction, Command)):
@ -86,7 +120,7 @@ class RawCommand(BaseModel):
spinner_message: str = "Processing..."
spinner_type: str = "dots"
spinner_style: str = OneColors.CYAN
spinner_kwargs: dict[str, Any] = Field(default_factory=dict)
spinner_speed: float = 1.0
before_hooks: list[Callable] = Field(default_factory=list)
success_hooks: list[Callable] = Field(default_factory=list)
@ -98,9 +132,9 @@ class RawCommand(BaseModel):
retry: bool = False
retry_all: bool = False
retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
requires_input: bool | None = None
hidden: bool = False
help_text: str = ""
help_epilog: str = ""
@field_validator("retry_policy")
@classmethod
@ -126,6 +160,7 @@ def convert_commands(raw_commands: list[dict[str, Any]]) -> list[Command]:
}
)
)
return commands

7
falyx/console.py Normal file
View File

@ -0,0 +1,7 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""Global console instance for Falyx CLI applications."""
from rich.console import Console
from falyx.themes import get_nord_theme
console = Console(color_system="truecolor", theme=get_nord_theme())

View File

@ -19,11 +19,14 @@ from __future__ import annotations
import time
from datetime import datetime
from traceback import format_exception
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from rich.console import Console
from falyx.console import console
class ExecutionContext(BaseModel):
"""
@ -40,7 +43,7 @@ class ExecutionContext(BaseModel):
kwargs (dict): Keyword arguments passed to the action.
action (BaseAction | Callable): The action instance being executed.
result (Any | None): The result of the action, if successful.
exception (Exception | None): The exception raised, if execution failed.
exception (BaseException | None): The exception raised, if execution failed.
start_time (float | None): High-resolution performance start time.
end_time (float | None): High-resolution performance end time.
start_wall (datetime | None): Wall-clock timestamp when execution began.
@ -70,18 +73,21 @@ class ExecutionContext(BaseModel):
name: str
args: tuple = ()
kwargs: dict = {}
kwargs: dict = Field(default_factory=dict)
action: Any
result: Any | None = None
exception: Exception | None = None
traceback: str | None = None
_exception: BaseException | None = None
start_time: float | None = None
end_time: float | None = None
start_wall: datetime | None = None
end_wall: datetime | None = None
index: int | None = None
extra: dict[str, Any] = Field(default_factory=dict)
console: Console = Field(default_factory=lambda: Console(color_system="auto"))
console: Console = console
shared_context: SharedContext | None = None
@ -118,11 +124,33 @@ class ExecutionContext(BaseModel):
def status(self) -> str:
return "OK" if self.success else "ERROR"
@property
def exception(self) -> BaseException | None:
return self._exception
@exception.setter
def exception(self, exc: BaseException | None):
self._exception = exc
if exc is not None:
self.traceback = "".join(format_exception(exc)).strip()
@property
def signature(self) -> str:
"""
Returns a string representation of the action signature, including
its name and arguments.
"""
args = ", ".join(map(repr, self.args))
kwargs = ", ".join(f"{key}={value!r}" for key, value in self.kwargs.items())
signature = ", ".join(filter(None, [args, kwargs]))
return f"{self.action} ({signature})"
def as_dict(self) -> dict:
return {
"name": self.name,
"result": self.result,
"exception": repr(self.exception) if self.exception else None,
"traceback": self.traceback,
"duration": self.duration,
"extra": self.extra,
}
@ -140,9 +168,9 @@ class ExecutionContext(BaseModel):
message.append(f"Duration: {summary['duration']:.3f}s | ")
if summary["exception"]:
message.append(f"Exception: {summary['exception']}")
message.append(f"Exception: {summary['exception']}")
else:
message.append(f"Result: {summary['result']}")
message.append(f"Result: {summary['result']}")
(logger or self.console.print)("".join(message))
def to_log_line(self) -> str:
@ -192,7 +220,7 @@ class SharedContext(BaseModel):
Attributes:
name (str): Identifier for the context (usually the parent action name).
results (list[Any]): Captures results from each action, in order of execution.
errors (list[tuple[int, Exception]]): Indexed list of errors from failed actions.
errors (list[tuple[int, BaseException]]): Indexed list of errors from failed actions.
current_index (int): Index of the currently executing action (used in chains).
is_parallel (bool): Whether the context is used in parallel mode (ActionGroup).
shared_result (Any | None): Optional shared value available to all actions in
@ -217,7 +245,7 @@ class SharedContext(BaseModel):
name: str
action: Any
results: list[Any] = Field(default_factory=list)
errors: list[tuple[int, Exception]] = Field(default_factory=list)
errors: list[tuple[int, BaseException]] = Field(default_factory=list)
current_index: int = -1
is_parallel: bool = False
shared_result: Any | None = None
@ -229,7 +257,7 @@ class SharedContext(BaseModel):
def add_result(self, result: Any) -> None:
self.results.append(result)
def add_error(self, index: int, error: Exception) -> None:
def add_error(self, index: int, error: BaseException) -> None:
self.errors.append((index, error))
def set_shared_result(self, result: Any) -> None:

View File

@ -1,5 +1,18 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""debug.py"""
"""
Provides debug logging hooks for Falyx action execution.
This module defines lifecycle hook functions (`log_before`, `log_success`, `log_after`, `log_error`)
that can be registered with a `HookManager` to trace command execution.
Logs include:
- Action invocation with argument signature
- Success result (with truncation for large outputs)
- Errors with full exception info
- Total runtime duration after execution
Also exports `register_debug_hooks()` to register all log hooks in bulk.
"""
from falyx.context import ExecutionContext
from falyx.hook_manager import HookManager, HookType
from falyx.logger import logger
@ -8,9 +21,9 @@ from falyx.logger import logger
def log_before(context: ExecutionContext):
"""Log the start of an action."""
args = ", ".join(map(repr, context.args))
kwargs = ", ".join(f"{k}={v!r}" for k, v in context.kwargs.items())
kwargs = ", ".join(f"{key}={value!r}" for key, value in context.kwargs.items())
signature = ", ".join(filter(None, [args, kwargs]))
logger.info("[%s] 🚀 Starting %s(%s)", context.name, context.action, signature)
logger.info("[%s] Starting -> %s(%s)", context.name, context.action, signature)
def log_success(context: ExecutionContext):
@ -18,18 +31,18 @@ def log_success(context: ExecutionContext):
result_str = repr(context.result)
if len(result_str) > 100:
result_str = f"{result_str[:100]} ..."
logger.debug("[%s] Success Result: %s", context.name, result_str)
logger.debug("[%s] Success -> Result: %s", context.name, result_str)
def log_after(context: ExecutionContext):
"""Log the completion of an action, regardless of success or failure."""
logger.debug("[%s] ⏱️ Finished in %.3fs", context.name, context.duration)
logger.debug("[%s] Finished in %.3fs", context.name, context.duration)
def log_error(context: ExecutionContext):
"""Log an error that occurred during the action."""
logger.error(
"[%s] Error (%s): %s",
"[%s] Error (%s): %s",
context.name,
type(context.exception).__name__,
context.exception,

View File

@ -1,5 +1,28 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""exceptions.py"""
"""
Defines all custom exception classes used in the Falyx CLI framework.
These exceptions provide structured error handling for common failure cases,
including command conflicts, invalid actions or hooks, parser errors, and execution guards
like circuit breakers or empty workflows.
All exceptions inherit from `FalyxError`, the base exception for the framework.
Exception Hierarchy:
- FalyxError
├── CommandAlreadyExistsError
├── InvalidHookError
├── InvalidActionError
├── NotAFalyxError
├── CircuitBreakerOpen
├── EmptyChainError
├── EmptyGroupError
├── EmptyPoolError
└── CommandArgumentError
These are raised internally throughout the Falyx system to signal user-facing or
developer-facing problems that should be caught and reported.
"""
class FalyxError(Exception):
@ -30,5 +53,13 @@ class EmptyChainError(FalyxError):
"""Exception raised when the chain is empty."""
class EmptyGroupError(FalyxError):
"""Exception raised when the chain is empty."""
class EmptyPoolError(FalyxError):
"""Exception raised when the chain is empty."""
class CommandArgumentError(FalyxError):
"""Exception raised when there is an error in the command argument parser."""

View File

@ -1,40 +1,62 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
execution_registry.py
Provides the `ExecutionRegistry`, a centralized runtime store for capturing and inspecting
the execution history of Falyx actions.
This module provides the `ExecutionRegistry`, a global class for tracking and
introspecting the execution history of Falyx actions.
The registry automatically records every `ExecutionContext` created during action
execution—including context metadata, results, exceptions, duration, and tracebacks.
It supports filtering, summarization, and visual inspection via a Rich-rendered table.
The registry captures `ExecutionContext` instances from all executed actions, making it
easy to debug, audit, and visualize workflow behavior over time. It supports retrieval,
filtering, clearing, and formatted summary display.
Designed for:
- Workflow debugging and CLI diagnostics
- Interactive history browsing or replaying previous runs
- Providing user-visible `history` or `last-result` commands inside CLI apps
Core Features:
- Stores all action execution contexts globally (with access by name).
- Provides live execution summaries in a rich table format.
- Enables creation of a built-in Falyx Action to print history on demand.
- Integrates with Falyx's introspectable and hook-driven execution model.
Intended for:
- Debugging and diagnostics
- Post-run inspection of CLI workflows
- Interactive tools built with Falyx
Key Features:
- Global, in-memory store of all `ExecutionContext` objects (by name, index, or full list)
- Thread-safe indexing and summary display
- Traceback-aware result inspection and filtering by status (success/error)
- Used by built-in `History` command in Falyx CLI
Example:
from falyx.execution_registry import ExecutionRegistry as er
# Record a context
er.record(context)
# Display a rich table summary
er.summary()
# Print the last non-ignored result
er.summary(last_result=True)
# Clear execution history
er.summary(clear=True)
Note:
The registry is volatile and cleared on each process restart or when `clear()` is called.
All data is retained in memory only.
Public Interface:
- record(context): Log an ExecutionContext and assign index.
- get_all(): List all stored contexts.
- get_by_name(name): Retrieve all contexts by action name.
- get_latest(): Retrieve the most recent context.
- clear(): Reset the registry.
- summary(...): Rich console summary of stored execution results.
"""
from __future__ import annotations
from collections import defaultdict
from datetime import datetime
from typing import Dict, List
from threading import Lock
from typing import Literal
from rich import box
from rich.console import Console
from rich.table import Table
from falyx.console import console
from falyx.context import ExecutionContext
from falyx.logger import logger
from falyx.themes import OneColors
@ -44,64 +66,181 @@ class ExecutionRegistry:
"""
Global registry for recording and inspecting Falyx action executions.
This class captures every `ExecutionContext` generated by a Falyx `Action`,
`ChainedAction`, or `ActionGroup`, maintaining both full history and
name-indexed access for filtered analysis.
This class captures every `ExecutionContext` created by Falyx Actions,
tracking metadata, results, exceptions, and performance metrics. It enables
rich introspection, post-execution inspection, and formatted summaries
suitable for interactive and headless CLI use.
Methods:
- record(context): Stores an ExecutionContext, logging a summary line.
- get_all(): Returns the list of all recorded executions.
- get_by_name(name): Returns all executions with the given action name.
- get_latest(): Returns the most recent execution.
- clear(): Wipes the registry for a fresh run.
- summary(): Renders a formatted Rich table of all execution results.
Data is retained in memory until cleared or process exit.
Use Cases:
- Debugging chained or factory-generated workflows
- Viewing results and exceptions from multiple runs
- Embedding a diagnostic command into your CLI for user support
- Auditing chained or dynamic workflows
- Rendering execution history in a help/debug menu
- Accessing previous results or errors for reuse
Note:
This registry is in-memory and not persistent. It's reset each time the process
restarts or `clear()` is called.
Example:
ExecutionRegistry.record(context)
ExecutionRegistry.summary()
Attributes:
_store_by_name (dict): Maps action name → list of ExecutionContext objects.
_store_by_index (dict): Maps numeric index → ExecutionContext.
_store_all (list): Ordered list of all contexts.
_index (int): Global counter for assigning unique execution indices.
_lock (Lock): Thread lock for atomic writes to the registry.
_console (Console): Rich console used for rendering summaries.
"""
_store_by_name: Dict[str, List[ExecutionContext]] = defaultdict(list)
_store_all: List[ExecutionContext] = []
_console = Console(color_system="auto")
_store_by_name: dict[str, list[ExecutionContext]] = defaultdict(list)
_store_by_index: dict[int, ExecutionContext] = {}
_store_all: list[ExecutionContext] = []
_console: Console = console
_index = 0
_lock = Lock()
@classmethod
def record(cls, context: ExecutionContext):
"""Record an execution context."""
"""
Record an execution context and assign a unique index.
This method logs the context, appends it to the registry,
and makes it available for future summary or filtering.
Args:
context (ExecutionContext): The context to be tracked.
"""
logger.debug(context.to_log_line())
with cls._lock:
context.index = cls._index
cls._store_by_index[cls._index] = context
cls._index += 1
cls._store_by_name[context.name].append(context)
cls._store_all.append(context)
@classmethod
def get_all(cls) -> List[ExecutionContext]:
def get_all(cls) -> list[ExecutionContext]:
"""
Return all recorded execution contexts in order of execution.
Returns:
list[ExecutionContext]: All stored action contexts.
"""
return cls._store_all
@classmethod
def get_by_name(cls, name: str) -> List[ExecutionContext]:
def get_by_name(cls, name: str) -> list[ExecutionContext]:
"""
Retrieve all executions recorded under a given action name.
Args:
name (str): The name of the action.
Returns:
list[ExecutionContext]: Matching contexts, or empty if none found.
"""
return cls._store_by_name.get(name, [])
@classmethod
def get_latest(cls) -> ExecutionContext:
"""
Return the most recent execution context.
Returns:
ExecutionContext: The last recorded context.
"""
return cls._store_all[-1]
@classmethod
def clear(cls):
"""
Clear all stored execution data and reset internal indices.
This operation is destructive and cannot be undone.
"""
cls._store_by_name.clear()
cls._store_all.clear()
cls._store_by_index.clear()
@classmethod
def summary(cls):
table = Table(title="📊 Execution History", expand=True, box=box.SIMPLE)
def summary(
cls,
name: str = "",
index: int | None = None,
result_index: int | None = None,
clear: bool = False,
last_result: bool = False,
status: Literal["all", "success", "error"] = "all",
):
"""
Display a formatted Rich table of recorded executions.
Supports filtering by action name, index, or execution status.
Can optionally show only the last result or a specific indexed result.
Also supports clearing the registry interactively.
Args:
name (str): Filter by action name.
index (int | None): Filter by specific execution index.
result_index (int | None): Print result (or traceback) of a specific index.
clear (bool): If True, clears the registry and exits.
last_result (bool): If True, prints only the most recent result.
status (Literal): One of "all", "success", or "error" to filter displayed rows.
"""
if clear:
cls.clear()
cls._console.print(f"[{OneColors.GREEN}]✅ Execution history cleared.")
return
if last_result:
for ctx in reversed(cls._store_all):
if not ctx.action.ignore_in_history:
cls._console.print(f"{ctx.signature}:")
if ctx.traceback:
cls._console.print(ctx.traceback)
else:
cls._console.print(ctx.result)
return
cls._console.print(
f"[{OneColors.DARK_RED}]❌ No valid executions found to display last result."
)
return
if result_index is not None and result_index >= 0:
try:
result_context = cls._store_by_index[result_index]
except KeyError:
cls._console.print(
f"[{OneColors.DARK_RED}]❌ No execution found for index {result_index}."
)
return
cls._console.print(f"{result_context.signature}:")
if result_context.traceback:
cls._console.print(result_context.traceback)
else:
cls._console.print(result_context.result)
return
if name:
contexts = cls.get_by_name(name)
if not contexts:
cls._console.print(
f"[{OneColors.DARK_RED}]❌ No executions found for action '{name}'."
)
return
title = f"📊 Execution History for '{contexts[0].name}'"
elif index is not None and index >= 0:
try:
contexts = [cls._store_by_index[index]]
print(contexts)
except KeyError:
cls._console.print(
f"[{OneColors.DARK_RED}]❌ No execution found for index {index}."
)
return
title = f"📊 Execution History for Index {index}"
else:
contexts = cls.get_all()
title = "📊 Execution History"
table = Table(title=title, expand=True, box=box.SIMPLE)
table.add_column("Index", justify="right", style="dim")
table.add_column("Name", style="bold cyan")
table.add_column("Start", justify="right", style="dim")
table.add_column("End", justify="right", style="dim")
@ -109,7 +248,7 @@ class ExecutionRegistry:
table.add_column("Status", style="bold")
table.add_column("Result / Exception", overflow="fold")
for ctx in cls.get_all():
for ctx in contexts:
start = (
datetime.fromtimestamp(ctx.start_time).strftime("%H:%M:%S")
if ctx.start_time
@ -122,15 +261,19 @@ class ExecutionRegistry:
)
duration = f"{ctx.duration:.3f}s" if ctx.duration else "n/a"
if ctx.exception:
status = f"[{OneColors.DARK_RED}]❌ Error"
result = repr(ctx.exception)
if ctx.exception and status.lower() in ["all", "error"]:
final_status = f"[{OneColors.DARK_RED}]❌ Error"
final_result = repr(ctx.exception)
elif status.lower() in ["all", "success"]:
final_status = f"[{OneColors.GREEN}]✅ Success"
final_result = repr(ctx.result)
if len(final_result) > 50:
final_result = f"{final_result[:50]}..."
else:
status = f"[{OneColors.GREEN}]✅ Success"
result = repr(ctx.result)
if len(result) > 1000:
result = f"{result[:1000]}..."
continue
table.add_row(ctx.name, start, end, duration, status, result)
table.add_row(
str(ctx.index), ctx.name, start, end, duration, final_status, final_result
)
cls._console.print(table)

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,26 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""hook_manager.py"""
"""
Defines the `HookManager` and `HookType` used in the Falyx CLI framework to manage
execution lifecycle hooks around actions and commands.
The hook system enables structured callbacks for important stages in a Falyx action's
execution, such as before execution, after success, upon error, and teardown. These
can be used for logging, side effects, diagnostics, metrics, and rollback logic.
Key Components:
- HookType: Enum categorizing supported hook lifecycle stages
- HookManager: Core class for registering and invoking hooks during action execution
- Hook: Union of sync and async callables accepting an `ExecutionContext`
Usage:
hooks = HookManager()
hooks.register(HookType.BEFORE, log_before)
"""
from __future__ import annotations
import inspect
from enum import Enum
from typing import Awaitable, Callable, Dict, List, Optional, Union
from typing import Awaitable, Callable, Union
from falyx.context import ExecutionContext
from falyx.logger import logger
@ -15,7 +31,27 @@ Hook = Union[
class HookType(Enum):
"""Enum for hook types to categorize the hooks."""
"""
Enum for supported hook lifecycle phases in Falyx.
HookType is used to classify lifecycle events that can be intercepted
with user-defined callbacks.
Members:
BEFORE: Run before the action is invoked.
ON_SUCCESS: Run after successful completion.
ON_ERROR: Run when an exception occurs.
AFTER: Run after success or failure (always runs).
ON_TEARDOWN: Run at the very end, for resource cleanup.
Aliases:
"success""on_success"
"error""on_error"
"teardown""on_teardown"
Example:
HookType("error") → HookType.ON_ERROR
"""
BEFORE = "before"
ON_SUCCESS = "on_success"
@ -24,29 +60,80 @@ class HookType(Enum):
ON_TEARDOWN = "on_teardown"
@classmethod
def choices(cls) -> List[HookType]:
def choices(cls) -> list[HookType]:
"""Return a list of all hook type choices."""
return list(cls)
@classmethod
def _get_alias(cls, value: str) -> str:
aliases = {
"success": "on_success",
"error": "on_error",
"teardown": "on_teardown",
}
return aliases.get(value, value)
@classmethod
def _missing_(cls, value: object) -> HookType:
if not isinstance(value, str):
raise ValueError(f"Invalid {cls.__name__}: {value!r}")
normalized = value.strip().lower()
alias = cls._get_alias(normalized)
for member in cls:
if member.value == alias:
return member
valid = ", ".join(member.value for member in cls)
raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}")
def __str__(self) -> str:
"""Return the string representation of the hook type."""
return self.value
class HookManager:
"""HookManager"""
"""
Manages lifecycle hooks for a command or action.
`HookManager` tracks user-defined callbacks to be run at key points in a command's
lifecycle: before execution, on success, on error, after completion, and during
teardown. Both sync and async hooks are supported.
Methods:
register(hook_type, hook): Register a callable for a given HookType.
clear(hook_type): Remove hooks for one or all lifecycle stages.
trigger(hook_type, context): Execute all hooks of a given type.
Example:
hooks = HookManager()
hooks.register(HookType.BEFORE, my_logger)
"""
def __init__(self) -> None:
self._hooks: Dict[HookType, List[Hook]] = {
self._hooks: dict[HookType, list[Hook]] = {
hook_type: [] for hook_type in HookType
}
def register(self, hook_type: HookType, hook: Hook):
if hook_type not in HookType:
raise ValueError(f"Unsupported hook type: {hook_type}")
def register(self, hook_type: HookType | str, hook: Hook):
"""
Register a new hook for a given lifecycle phase.
Args:
hook_type (HookType | str): The hook category (e.g. "before", "on_success").
hook (Callable): The hook function to register.
Raises:
ValueError: If the hook type is invalid.
"""
hook_type = HookType(hook_type)
self._hooks[hook_type].append(hook)
def clear(self, hook_type: Optional[HookType] = None):
def clear(self, hook_type: HookType | None = None):
"""
Clear registered hooks for one or all hook types.
Args:
hook_type (HookType | None): If None, clears all hooks.
"""
if hook_type:
self._hooks[hook_type] = []
else:
@ -54,6 +141,17 @@ class HookManager:
self._hooks[ht] = []
async def trigger(self, hook_type: HookType, context: ExecutionContext):
"""
Invoke all hooks registered for a given lifecycle phase.
Args:
hook_type (HookType): The lifecycle phase to trigger.
context (ExecutionContext): The execution context passed to each hook.
Raises:
Exception: Re-raises the original context.exception if a hook fails during
ON_ERROR. Other hook exceptions are logged and skipped.
"""
if hook_type not in self._hooks:
raise ValueError(f"Unsupported hook type: {hook_type}")
for hook in self._hooks[hook_type]:
@ -64,13 +162,12 @@ class HookManager:
hook(context)
except Exception as hook_error:
logger.warning(
"⚠️ Hook '%s' raised an exception during '%s' for '%s': %s",
"[Hook:%s] raised an exception during '%s' for '%s': %s",
hook.__name__,
hook_type,
context.name,
hook_error,
)
if hook_type == HookType.ON_ERROR:
assert isinstance(
context.exception, Exception

View File

@ -1,5 +1,32 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""hooks.py"""
"""
Defines reusable lifecycle hooks for Falyx Actions and Commands.
This module includes:
- `spinner_before_hook`: Automatically starts a spinner before an action runs.
- `spinner_teardown_hook`: Stops and clears the spinner after the action completes.
- `ResultReporter`: A success hook that displays a formatted result with duration.
- `CircuitBreaker`: A failure-aware hook manager that prevents repeated execution
after a configurable number of failures.
These hooks can be registered on `HookManager` instances via lifecycle stages
(`before`, `on_error`, `after`, etc.) to enhance resiliency and observability.
Intended for use with:
- Actions that require user feedback during long-running operations.
- Retryable or unstable actions
- Interactive CLI feedback
- Safety checks prior to execution
Example usage:
breaker = CircuitBreaker(max_failures=3)
hooks.register(HookType.BEFORE, breaker.before_hook)
hooks.register(HookType.ON_ERROR, breaker.error_hook)
hooks.register(HookType.AFTER, breaker.after_hook)
reporter = ResultReporter()
hooks.register(HookType.ON_SUCCESS, reporter.report)
"""
import time
from typing import Any, Callable
@ -9,6 +36,38 @@ from falyx.logger import logger
from falyx.themes import OneColors
async def spinner_before_hook(context: ExecutionContext):
"""Adds a spinner before the action starts."""
cmd = context.action
if cmd.options_manager is None:
return
sm = context.action.options_manager.spinners
if hasattr(cmd, "name"):
cmd_name = cmd.name
else:
cmd_name = cmd.key
await sm.add(
cmd_name,
cmd.spinner_message,
cmd.spinner_type,
cmd.spinner_style,
cmd.spinner_speed,
)
async def spinner_teardown_hook(context: ExecutionContext):
"""Removes the spinner after the action finishes (success or failure)."""
cmd = context.action
if cmd.options_manager is None:
return
if hasattr(cmd, "name"):
cmd_name = cmd.name
else:
cmd_name = cmd.key
sm = context.action.options_manager.spinners
await sm.remove(cmd_name)
class ResultReporter:
"""Reports the success of an action."""
@ -56,10 +115,10 @@ class CircuitBreaker:
if self.open_until:
if time.time() < self.open_until:
raise CircuitBreakerOpen(
f"🔴 Circuit open for '{name}' until {time.ctime(self.open_until)}."
f"Circuit open for '{name}' until {time.ctime(self.open_until)}."
)
else:
logger.info("🟢 Circuit closed again for '%s'.")
logger.info("Circuit closed again for '%s'.")
self.failures = 0
self.open_until = None
@ -67,7 +126,7 @@ class CircuitBreaker:
name = context.name
self.failures += 1
logger.warning(
"⚠️ CircuitBreaker: '%s' failure %s/%s.",
"CircuitBreaker: '%s' failure %s/%s.",
name,
self.failures,
self.max_failures,
@ -75,7 +134,7 @@ class CircuitBreaker:
if self.failures >= self.max_failures:
self.open_until = time.time() + self.reset_timeout
logger.error(
"🔴 Circuit opened for '%s' until %s.", name, time.ctime(self.open_until)
"Circuit opened for '%s' until %s.", name, time.ctime(self.open_until)
)
def after_hook(self, _: ExecutionContext):
@ -87,4 +146,4 @@ class CircuitBreaker:
def reset(self):
self.failures = 0
self.open_until = None
logger.info("🔄 Circuit reset.")
logger.info("Circuit reset.")

View File

@ -1,8 +1,26 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""init.py"""
"""
Project and global initializer for Falyx CLI environments.
This module defines functions to bootstrap a new Falyx-based CLI project or
create a global user-level configuration in `~/.config/falyx`.
Functions:
- `init_project(name: str)`: Creates a new CLI project folder with `tasks.py`
and `falyx.yaml` using example actions and config structure.
- `init_global()`: Creates a shared config in the user's home directory for
defining reusable or always-available CLI commands.
Generated files include:
- `tasks.py`: Python module with `Action`, `ChainedAction`, and async examples
- `falyx.yaml`: YAML config with command definitions for CLI entry points
Used by:
- The `falyx init` and `falyx init --global` commands
"""
from pathlib import Path
from rich.console import Console
from falyx.console import console
TEMPLATE_TASKS = """\
# This file is used by falyx.yaml to define CLI actions.
@ -11,9 +29,7 @@ TEMPLATE_TASKS = """\
import asyncio
import json
from falyx.action import Action, ChainedAction
from falyx.io_action import ShellAction
from falyx.selection_action import SelectionAction
from falyx.action import Action, ChainedAction, ShellAction, SelectionAction
post_ids = ["1", "2", "3", "4", "5"]
@ -100,10 +116,8 @@ commands:
aliases: [clean, cleanup]
"""
console = Console(color_system="auto")
def init_project(name: str = ".") -> None:
def init_project(name: str) -> None:
target = Path(name).resolve()
target.mkdir(parents=True, exist_ok=True)

View File

@ -1,5 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""logger.py"""
"""Global logger instance for Falyx CLI applications."""
import logging
logger = logging.getLogger("falyx")
logger: logging.Logger = logging.getLogger("falyx")

View File

@ -1,8 +1,27 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `MenuOption` and `MenuOptionMap`, core components used to construct
interactive menus within Falyx Actions such as `MenuAction` and `PromptMenuAction`.
Each `MenuOption` represents a single actionable choice with a description,
styling, and a bound `BaseAction`. `MenuOptionMap` manages collections of these
options, including support for reserved keys like `B` (Back) and `X` (Exit), which
can trigger navigation signals when selected.
These constructs enable declarative and reusable menu definitions in both code and config.
Key Components:
- MenuOption: A user-facing label and action binding
- MenuOptionMap: A key-aware container for menu options, with reserved entry support
"""
from __future__ import annotations
from dataclasses import dataclass
from falyx.action import BaseAction
from prompt_toolkit.formatted_text import FormattedText
from falyx.action.base_action import BaseAction
from falyx.signals import BackSignal, QuitSignal
from falyx.themes import OneColors
from falyx.utils import CaseInsensitiveDict
@ -10,7 +29,25 @@ from falyx.utils import CaseInsensitiveDict
@dataclass
class MenuOption:
"""Represents a single menu option with a description and an action to execute."""
"""
Represents a single menu entry, including its label and associated action.
Used in conjunction with `MenuOptionMap` to define interactive command menus.
Each `MenuOption` contains a description (shown to the user), a `BaseAction`
to execute when selected, and an optional Rich-compatible style.
Attributes:
description (str): The label shown next to the menu key.
action (BaseAction): The action to invoke when selected.
style (str): A Rich-compatible color/style string for UI display.
Methods:
render(key): Returns a Rich-formatted string for menu display.
render_prompt(key): Returns a `FormattedText` object for use in prompt placeholders.
Raises:
TypeError: If `description` is not a string or `action` is not a `BaseAction`.
"""
description: str
action: BaseAction
@ -26,14 +63,39 @@ class MenuOption:
"""Render the menu option for display."""
return f"[{OneColors.WHITE}][{key}][/] [{self.style}]{self.description}[/]"
def render_prompt(self, key: str) -> FormattedText:
"""Render the menu option for prompt display."""
return FormattedText(
[(OneColors.WHITE, f"[{key}] "), (self.style, self.description)]
)
class MenuOptionMap(CaseInsensitiveDict):
"""
Manages menu options including validation, reserved key protection,
and special signal entries like Quit and Back.
A container for storing and managing `MenuOption` objects by key.
`MenuOptionMap` is used to define the set of available choices in a
Falyx menu. Keys are case-insensitive and mapped to `MenuOption` instances.
The map supports special reserved keys—`B` for Back and `X` for Exit—unless
explicitly disabled via `allow_reserved=False`.
This class enforces strict typing of menu options and prevents accidental
overwrites of reserved keys.
Args:
options (dict[str, MenuOption] | None): Initial options to populate the menu.
allow_reserved (bool): If True, allows overriding reserved keys.
Methods:
items(include_reserved): Returns an iterable of menu options,
optionally filtering out reserved keys.
Raises:
TypeError: If non-`MenuOption` values are assigned.
ValueError: If attempting to use or delete a reserved key without permission.
"""
RESERVED_KEYS = {"Q", "B"}
RESERVED_KEYS = {"B", "X"}
def __init__(
self,
@ -49,14 +111,14 @@ class MenuOptionMap(CaseInsensitiveDict):
def _inject_reserved_defaults(self):
from falyx.action import SignalAction
self._add_reserved(
"Q",
MenuOption("Exit", SignalAction("Quit", QuitSignal()), OneColors.DARK_RED),
)
self._add_reserved(
"B",
MenuOption("Back", SignalAction("Back", BackSignal()), OneColors.DARK_YELLOW),
)
self._add_reserved(
"X",
MenuOption("Exit", SignalAction("Quit", QuitSignal()), OneColors.DARK_RED),
)
def _add_reserved(self, key: str, option: MenuOption) -> None:
"""Add a reserved key, bypassing validation."""
@ -78,8 +140,20 @@ class MenuOptionMap(CaseInsensitiveDict):
raise ValueError(f"Cannot delete reserved option '{key}'.")
super().__delitem__(key)
def update(self, other=None, **kwargs):
"""Update the selection options with another dictionary."""
if other:
for key, option in other.items():
if not isinstance(option, MenuOption):
raise TypeError(f"Value for key '{key}' must be a SelectionOption.")
self[key] = option
for key, option in kwargs.items():
if not isinstance(option, MenuOption):
raise TypeError(f"Value for key '{key}' must be a SelectionOption.")
self[key] = option
def items(self, include_reserved: bool = True):
for k, v in super().items():
if not include_reserved and k in self.RESERVED_KEYS:
for key, option in super().items():
if not include_reserved and key in self.RESERVED_KEYS:
continue
yield k, v
yield key, option

12
falyx/mode.py Normal file
View File

@ -0,0 +1,12 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `FalyxMode`, an enum representing the different modes of operation for Falyx.
"""
from enum import Enum
class FalyxMode(Enum):
MENU = "menu"
RUN = "run"
PREVIEW = "preview"
RUN_ALL = "run-all"

View File

@ -1,18 +1,55 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""options_manager.py"""
"""
Manages global or scoped CLI options across namespaces for Falyx commands.
The `OptionsManager` provides a centralized interface for retrieving, setting, toggling,
and introspecting options defined in `argparse.Namespace` objects. It is used internally
by Falyx to pass and resolve runtime flags like `--verbose`, `--force-confirm`, etc.
Each option is stored under a namespace key (e.g., "cli_args", "user_config") to
support multiple sources of configuration.
Key Features:
- Safe getter/setter for typed option resolution
- Toggle support for boolean options (used by bottom bar toggles, etc.)
- Callable getter/toggler wrappers for dynamic UI bindings
- Namespace merging via `from_namespace`
Typical Usage:
options = OptionsManager()
options.from_namespace(args, namespace_name="cli_args")
if options.get("verbose"):
...
options.toggle("force_confirm")
value_fn = options.get_value_getter("dry_run")
toggle_fn = options.get_toggle_function("debug")
Used by:
- Falyx CLI runtime configuration
- Bottom bar toggles
- Dynamic flag injection into commands and actions
"""
from argparse import Namespace
from collections import defaultdict
from typing import Any, Callable
from falyx.logger import logger
from falyx.spinner_manager import SpinnerManager
class OptionsManager:
"""OptionsManager"""
"""
Manages CLI option state across multiple argparse namespaces.
Allows dynamic retrieval, setting, toggling, and introspection of command-line
options. Supports named namespaces (e.g., "cli_args") and is used throughout
Falyx for runtime configuration and bottom bar toggle integration.
"""
def __init__(self, namespaces: list[tuple[str, Namespace]] | None = None) -> None:
self.options: defaultdict = defaultdict(Namespace)
self.spinners = SpinnerManager()
if namespaces:
for namespace_name, namespace in namespaces:
self.from_namespace(namespace, namespace_name)

21
falyx/parser/__init__.py Normal file
View File

@ -0,0 +1,21 @@
"""
Falyx CLI Framework
Copyright (c) 2025 rtj.dev LLC.
Licensed under the MIT License. See LICENSE file for details.
"""
from .argument import Argument
from .argument_action import ArgumentAction
from .command_argument_parser import CommandArgumentParser
from .parsers import FalyxParsers, get_arg_parsers, get_root_parser, get_subparsers
__all__ = [
"Argument",
"ArgumentAction",
"CommandArgumentParser",
"get_arg_parsers",
"get_root_parser",
"get_subparsers",
"FalyxParsers",
]

151
falyx/parser/argument.py Normal file
View File

@ -0,0 +1,151 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines the `Argument` dataclass used by `CommandArgumentParser` to represent
individual command-line parameters in a structured, introspectable format.
Each `Argument` instance describes one CLI input, including its flags, type,
default behavior, action semantics, help text, and optional resolver integration
for dynamic evaluation.
Falyx uses this structure to support a declarative CLI design, providing flexible
argument parsing with full support for positional and keyword arguments, coercion,
completion, and help rendering.
Arguments should be created using `CommandArgumentParser.add_argument()`
or defined in YAML configurations, allowing for rich introspection and validation.
Key Attributes:
- `flags`: One or more short/long flags (e.g. `-v`, `--verbose`)
- `dest`: Internal name used as the key in parsed results
- `action`: `ArgumentAction` enum describing behavior (store, count, resolve, etc.)
- `type`: Type coercion or callable converter
- `default`: Optional fallback value
- `choices`: Allowed values, if restricted
- `nargs`: Number of expected values (`int`, `'?'`, `'*'`, `'+'`)
- `positional`: Whether this argument is positional (no flag)
- `resolver`: Optional `BaseAction` to resolve argument value dynamically
- `lazy_resolver`: Whether to defer resolution until needed
- `suggestions`: Optional completions for interactive shells
Used By:
- `CommandArgumentParser`
- `Falyx` runtime parsing
- Rich-based CLI help generation
- Completion and preview suggestions
"""
from dataclasses import dataclass
from typing import Any
from falyx.action.base_action import BaseAction
from falyx.parser.argument_action import ArgumentAction
@dataclass
class Argument:
"""
Represents a command-line argument.
Attributes:
flags (tuple[str, ...]): Short and long flags for the argument.
dest (str): The destination name for the argument.
action (ArgumentAction): The action to be taken when the argument is encountered.
type (Any): The type of the argument (e.g., str, int, float) or a callable that converts the argument value.
default (Any): The default value if the argument is not provided.
choices (list[str] | None): A list of valid choices for the argument.
required (bool): True if the argument is required, False otherwise.
help (str): Help text for the argument.
nargs (int | str | None): Number of arguments expected. Can be an int, '?', '*', '+', or None.
positional (bool): True if the argument is positional (no leading - or -- in flags), False otherwise.
resolver (BaseAction | None):
An action object that resolves the argument, if applicable.
lazy_resolver (bool): True if the resolver should be called lazily, False otherwise
suggestions (list[str] | None): Optional completions for interactive shells
"""
flags: tuple[str, ...]
dest: str
action: ArgumentAction = ArgumentAction.STORE
type: Any = str
default: Any = None
choices: list[str] | None = None
required: bool = False
help: str = ""
nargs: int | str | None = None
positional: bool = False
resolver: BaseAction | None = None
lazy_resolver: bool = False
suggestions: list[str] | None = None
def get_positional_text(self) -> str:
"""Get the positional text for the argument."""
text = ""
if self.positional:
if self.choices:
text = f"{{{','.join([str(choice) for choice in self.choices])}}}"
else:
text = self.dest
return text
def get_choice_text(self) -> str:
"""Get the choice text for the argument."""
choice_text = ""
if self.choices:
choice_text = f"{{{','.join([str(choice) for choice in self.choices])}}}"
elif (
self.action
in (
ArgumentAction.STORE,
ArgumentAction.APPEND,
ArgumentAction.EXTEND,
ArgumentAction.ACTION,
)
and not self.positional
):
choice_text = self.dest.upper()
elif self.action in (
ArgumentAction.STORE,
ArgumentAction.APPEND,
ArgumentAction.EXTEND,
ArgumentAction.ACTION,
) or isinstance(self.nargs, str):
choice_text = self.dest
if self.nargs == "?":
choice_text = f"[{choice_text}]"
elif self.nargs == "*":
choice_text = f"[{choice_text} ...]"
elif self.nargs == "+":
choice_text = f"{choice_text} [{choice_text} ...]"
return choice_text
def __eq__(self, other: object) -> bool:
if not isinstance(other, Argument):
return False
return (
self.flags == other.flags
and self.dest == other.dest
and self.action == other.action
and self.type == other.type
and self.choices == other.choices
and self.required == other.required
and self.nargs == other.nargs
and self.positional == other.positional
and self.default == other.default
and self.help == other.help
)
def __hash__(self) -> int:
return hash(
(
tuple(self.flags),
self.dest,
self.action,
self.type,
tuple(self.choices or []),
self.required,
self.nargs,
self.positional,
self.default,
self.help,
)
)

View File

@ -0,0 +1,94 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `ArgumentAction`, an enum used to standardize the behavior of CLI arguments
defined within Falyx command configurations.
Each member of this enum maps to a valid `argparse` like actions or Falyx-specific
behavior used during command argument parsing. This allows declarative configuration
of argument behavior when building CLI commands via `CommandArgumentParser`.
Supports alias coercion for shorthand or config-friendly values, and provides
a consistent interface for downstream argument handling logic.
Exports:
- ArgumentAction: Enum of allowed actions for command arguments.
Example:
ArgumentAction("store_true") → ArgumentAction.STORE_TRUE
ArgumentAction("true") → ArgumentAction.STORE_TRUE (via alias)
ArgumentAction("optional") → ArgumentAction.STORE_BOOL_OPTIONAL
"""
from __future__ import annotations
from enum import Enum
class ArgumentAction(Enum):
"""
Defines the action to be taken when the argument is encountered.
This enum mirrors the core behavior of Python's `argparse` actions, with a few
Falyx-specific extensions. It is used when defining command-line arguments for
`CommandArgumentParser` or YAML-based argument definitions.
Members:
ACTION: Invoke a callable as the argument handler (Falyx extension).
STORE: Store the provided value (default).
STORE_TRUE: Store `True` if the flag is present.
STORE_FALSE: Store `False` if the flag is present.
STORE_BOOL_OPTIONAL: Accept an optional bool (e.g., `--debug` or `--no-debug`).
APPEND: Append the value to a list.
EXTEND: Extend a list with multiple values.
COUNT: Count the number of occurrences.
HELP: Display help and exit.
TLDR: Display brief examples and exit.
Aliases:
- "true""store_true"
- "false""store_false"
- "optional""store_bool_optional"
Example:
ArgumentAction("true") → ArgumentAction.STORE_TRUE
"""
ACTION = "action"
STORE = "store"
STORE_TRUE = "store_true"
STORE_FALSE = "store_false"
STORE_BOOL_OPTIONAL = "store_bool_optional"
APPEND = "append"
EXTEND = "extend"
COUNT = "count"
HELP = "help"
TLDR = "tldr"
@classmethod
def choices(cls) -> list[ArgumentAction]:
"""Return a list of all argument actions."""
return list(cls)
@classmethod
def _get_alias(cls, value: str) -> str:
aliases = {
"optional": "store_bool_optional",
"true": "store_true",
"false": "store_false",
}
return aliases.get(value, value)
@classmethod
def _missing_(cls, value: object) -> ArgumentAction:
if not isinstance(value, str):
raise ValueError(f"Invalid {cls.__name__}: {value!r}")
normalized = value.strip().lower()
alias = cls._get_alias(normalized)
for member in cls:
if member.value == alias:
return member
valid = ", ".join(member.value for member in cls)
raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}")
def __str__(self) -> str:
"""Return the string representation of the argument action."""
return self.value

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,52 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Type utilities and argument state models for Falyx's custom CLI argument parser.
This module provides specialized helpers and data structures used by
the `CommandArgumentParser` to handle non-standard parsing behavior.
Contents:
- `true_none` / `false_none`: Type coercion utilities that allow tri-state boolean
semantics (True, False, None). These are especially useful for supporting
`--flag` / `--no-flag` optional booleans in CLI arguments.
- `ArgumentState`: Tracks whether an `Argument` has been consumed during parsing.
- `TLDRExample`: A structured example for showing usage snippets and descriptions,
used in TLDR views.
These tools support richer expressiveness and user-friendly ergonomics in
Falyx's declarative command-line interfaces.
"""
from dataclasses import dataclass
from typing import Any
from falyx.parser.argument import Argument
@dataclass
class ArgumentState:
"""Tracks an argument and whether it has been consumed."""
arg: Argument
consumed: bool = False
@dataclass(frozen=True)
class TLDRExample:
"""Represents a usage example for TLDR output."""
usage: str
description: str
def true_none(value: Any) -> bool | None:
"""Return True if value is not None, else None."""
if value is None:
return None
return True
def false_none(value: Any) -> bool | None:
"""Return False if value is not None, else None."""
if value is None:
return None
return False

396
falyx/parser/parsers.py Normal file
View File

@ -0,0 +1,396 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Provides the argument parser infrastructure for the Falyx CLI.
This module defines the `FalyxParsers` dataclass and related utilities for building
structured CLI interfaces with argparse. It supports top-level CLI commands like
`run`, `run-all`, `preview`, `list`, and `version`, and integrates seamlessly with
registered `Command` objects for dynamic help, usage generation, and argument handling.
Key Components:
- `FalyxParsers`: Container for all CLI subparsers.
- `get_arg_parsers()`: Factory for generating full parser suite.
- `get_root_parser()`: Creates the root-level CLI parser with global options.
- `get_subparsers()`: Helper to attach subcommand parsers to the root parser.
Used internally by the Falyx CLI `run()` entry point to parse arguments and route
execution across commands and workflows.
"""
from argparse import (
REMAINDER,
ArgumentParser,
Namespace,
RawDescriptionHelpFormatter,
_SubParsersAction,
)
from dataclasses import asdict, dataclass
from typing import Any, Sequence
from falyx.command import Command
@dataclass
class FalyxParsers:
"""Defines the argument parsers for the Falyx CLI."""
root: ArgumentParser
subparsers: _SubParsersAction
run: ArgumentParser
run_all: ArgumentParser
preview: ArgumentParser
list: ArgumentParser
version: ArgumentParser
def parse_args(self, args: Sequence[str] | None = None) -> Namespace:
"""Parse the command line arguments."""
return self.root.parse_args(args)
def as_dict(self) -> dict[str, ArgumentParser]:
"""Convert the FalyxParsers instance to a dictionary."""
return asdict(self)
def get_parser(self, name: str) -> ArgumentParser | None:
"""Get the parser by name."""
return self.as_dict().get(name)
def get_root_parser(
prog: str | None = "falyx",
usage: str | None = None,
description: str | None = "Falyx CLI - Run structured async command workflows.",
epilog: str | None = "Tip: Use 'falyx run ?' to show available commands.",
parents: Sequence[ArgumentParser] | None = None,
prefix_chars: str = "-",
fromfile_prefix_chars: str | None = None,
argument_default: Any = None,
conflict_handler: str = "error",
add_help: bool = True,
allow_abbrev: bool = True,
exit_on_error: bool = True,
) -> ArgumentParser:
"""
Construct the root-level ArgumentParser for the Falyx CLI.
This parser handles global arguments shared across subcommands and can serve
as the base parser for the Falyx CLI or standalone applications. It includes
options for verbosity, debug logging, and version output.
Args:
prog (str | None): Name of the program (e.g., 'falyx').
usage (str | None): Optional custom usage string.
description (str | None): Description shown in the CLI help.
epilog (str | None): Message displayed at the end of help output.
parents (Sequence[ArgumentParser] | None): Optional parent parsers.
prefix_chars (str): Characters to denote optional arguments (default: "-").
fromfile_prefix_chars (str | None): Prefix to indicate argument file input.
argument_default (Any): Global default value for arguments.
conflict_handler (str): Strategy to resolve conflicting argument names.
add_help (bool): Whether to include help (`-h/--help`) in this parser.
allow_abbrev (bool): Allow abbreviated long options.
exit_on_error (bool): Exit immediately on error or raise an exception.
Returns:
ArgumentParser: The root parser with global options attached.
Notes:
```
Includes the following arguments:
--never-prompt : Run in non-interactive mode.
-v / --verbose : Enable debug logging.
--debug-hooks : Enable hook lifecycle debug logs.
--version : Print the Falyx version.
```
"""
parser = ArgumentParser(
prog=prog,
usage=usage,
description=description,
epilog=epilog,
parents=parents if parents else [],
prefix_chars=prefix_chars,
fromfile_prefix_chars=fromfile_prefix_chars,
argument_default=argument_default,
conflict_handler=conflict_handler,
add_help=add_help,
allow_abbrev=allow_abbrev,
exit_on_error=exit_on_error,
)
parser.add_argument(
"--never-prompt",
action="store_true",
help="Run in non-interactive mode with all prompts bypassed.",
)
parser.add_argument(
"-v", "--verbose", action="store_true", help=f"Enable debug logging for {prog}."
)
parser.add_argument(
"--debug-hooks",
action="store_true",
help="Enable default lifecycle debug logging",
)
parser.add_argument("--version", action="store_true", help=f"Show {prog} version")
return parser
def get_subparsers(
parser: ArgumentParser,
title: str = "Falyx Commands",
description: str | None = "Available commands for the Falyx CLI.",
) -> _SubParsersAction:
"""
Create and return a subparsers object for registering Falyx CLI subcommands.
This function adds a `subparsers` block to the given root parser, enabling
structured subcommands such as `run`, `run-all`, `preview`, etc.
Args:
parser (ArgumentParser): The root parser to attach the subparsers to.
title (str): Title used in help output to group subcommands.
description (str | None): Optional text describing the group of subcommands.
Returns:
_SubParsersAction: The subparsers object that can be used to add new CLI subcommands.
Raises:
TypeError: If `parser` is not an instance of `ArgumentParser`.
Example:
```python
>>> parser = get_root_parser()
>>> subparsers = get_subparsers(parser, title="Available Commands")
>>> subparsers.add_parser("run", help="Run a Falyx command")
```
"""
if not isinstance(parser, ArgumentParser):
raise TypeError("parser must be an instance of ArgumentParser")
subparsers = parser.add_subparsers(
title=title,
description=description,
dest="command",
)
return subparsers
def get_arg_parsers(
prog: str | None = "falyx",
usage: str | None = None,
description: str | None = "Falyx CLI - Run structured async command workflows.",
epilog: (
str | None
) = "Tip: Use 'falyx run ?[COMMAND]' to preview any command from the CLI.",
parents: Sequence[ArgumentParser] | None = None,
prefix_chars: str = "-",
fromfile_prefix_chars: str | None = None,
argument_default: Any = None,
conflict_handler: str = "error",
add_help: bool = True,
allow_abbrev: bool = True,
exit_on_error: bool = True,
commands: dict[str, Command] | None = None,
root_parser: ArgumentParser | None = None,
subparsers: _SubParsersAction | None = None,
) -> FalyxParsers:
"""
Create and return the full suite of argument parsers used by the Falyx CLI.
This function builds the root parser and all subcommand parsers used for structured
CLI workflows in Falyx. It supports standard subcommands including `run`, `run-all`,
`preview`, `list`, and `version`, and integrates with registered `Command` objects
to populate dynamic help and usage documentation.
Args:
prog (str | None): Program name to display in help and usage messages.
usage (str | None): Optional usage message to override the default.
description (str | None): Description for the CLI root parser.
epilog (str | None): Epilog message shown after the help text.
parents (Sequence[ArgumentParser] | None): Optional parent parsers.
prefix_chars (str): Characters that prefix optional arguments.
fromfile_prefix_chars (str | None): Prefix character for reading args from file.
argument_default (Any): Default value for arguments if not specified.
conflict_handler (str): Strategy for resolving conflicting arguments.
add_help (bool): Whether to add the `-h/--help` option to the root parser.
allow_abbrev (bool): Whether to allow abbreviated long options.
exit_on_error (bool): Whether the parser exits on error or raises.
commands (dict[str, Command] | None): Optional dictionary of registered commands
to populate help and subcommand descriptions dynamically.
root_parser (ArgumentParser | None): Custom root parser to use instead of building one.
subparsers (_SubParsersAction | None): Optional existing subparser object to extend.
Returns:
FalyxParsers: A structured container of all parsers, including `run`, `run-all`,
`preview`, `list`, `version`, and the root parser.
Raises:
TypeError: If `root_parser` is not an instance of ArgumentParser or
`subparsers` is not an instance of _SubParsersAction.
Example:
```python
>>> parsers = get_arg_parsers(commands=my_command_dict)
>>> args = parsers.root.parse_args()
```
Notes:
- This function integrates dynamic command usage and descriptions if the
`commands` argument is provided.
- The `run` parser supports additional options for retry logic and confirmation
prompts.
- The `run-all` parser executes all commands matching a tag.
- Use `falyx run ?[COMMAND]` from the CLI to preview a command.
"""
if epilog is None:
epilog = f"Tip: Use '{prog} run ?' to show available commands."
if root_parser is None:
parser = get_root_parser(
prog=prog,
usage=usage,
description=description,
epilog=epilog,
parents=parents,
prefix_chars=prefix_chars,
fromfile_prefix_chars=fromfile_prefix_chars,
argument_default=argument_default,
conflict_handler=conflict_handler,
add_help=add_help,
allow_abbrev=allow_abbrev,
exit_on_error=exit_on_error,
)
else:
if not isinstance(root_parser, ArgumentParser):
raise TypeError("root_parser must be an instance of ArgumentParser")
parser = root_parser
if subparsers is None:
if prog == "falyx":
subparsers = get_subparsers(
parser,
title="Falyx Commands",
description="Available commands for the Falyx CLI.",
)
else:
subparsers = get_subparsers(parser, title="subcommands", description=None)
if not isinstance(subparsers, _SubParsersAction):
raise TypeError("subparsers must be an instance of _SubParsersAction")
run_description = ["Run a command by its key or alias.\n"]
run_description.append("commands:")
if isinstance(commands, dict):
for command in commands.values():
run_description.append(command.usage)
command_description = command.help_text or command.description
run_description.append(f"{' '*24}{command_description}")
run_epilog = (
f"Tip: Use '{prog} run ?[COMMAND]' to preview commands by their key or alias."
)
run_parser = subparsers.add_parser(
"run",
help="Run a specific command",
description="\n".join(run_description),
epilog=run_epilog,
formatter_class=RawDescriptionHelpFormatter,
)
run_parser.add_argument(
"name", help="Run a command by its key or alias", metavar="COMMAND"
)
run_parser.add_argument(
"--summary",
action="store_true",
help="Print an execution summary after command completes",
)
run_parser.add_argument(
"--retries", type=int, help="Number of retries on failure", default=0
)
run_parser.add_argument(
"--retry-delay",
type=float,
help="Initial delay between retries in (seconds)",
default=0,
)
run_parser.add_argument(
"--retry-backoff", type=float, help="Backoff factor for retries", default=0
)
run_group = run_parser.add_mutually_exclusive_group(required=False)
run_group.add_argument(
"-c",
"--confirm",
dest="force_confirm",
action="store_true",
help="Force confirmation prompts",
)
run_group.add_argument(
"-s",
"--skip-confirm",
dest="skip_confirm",
action="store_true",
help="Skip confirmation prompts",
)
run_parser.add_argument(
"command_args",
nargs=REMAINDER,
help="Arguments to pass to the command (if applicable)",
metavar="ARGS",
)
run_all_parser = subparsers.add_parser(
"run-all", help="Run all commands with a given tag"
)
run_all_parser.add_argument("-t", "--tag", required=True, help="Tag to match")
run_all_parser.add_argument(
"--summary",
action="store_true",
help="Print a summary after all tagged commands run",
)
run_all_parser.add_argument(
"--retries", type=int, help="Number of retries on failure", default=0
)
run_all_parser.add_argument(
"--retry-delay",
type=float,
help="Initial delay between retries in (seconds)",
default=0,
)
run_all_parser.add_argument(
"--retry-backoff", type=float, help="Backoff factor for retries", default=0
)
run_all_group = run_all_parser.add_mutually_exclusive_group(required=False)
run_all_group.add_argument(
"-c",
"--confirm",
dest="force_confirm",
action="store_true",
help="Force confirmation prompts",
)
run_all_group.add_argument(
"-s",
"--skip-confirm",
dest="skip_confirm",
action="store_true",
help="Skip confirmation prompts",
)
preview_parser = subparsers.add_parser(
"preview", help="Preview a command without running it"
)
preview_parser.add_argument("name", help="Key, alias, or description of the command")
list_parser = subparsers.add_parser(
"list", help="List all available commands with tags"
)
list_parser.add_argument(
"-t", "--tag", help="Filter commands by tag (case-insensitive)", default=None
)
version_parser = subparsers.add_parser("version", help=f"Show {prog} version")
return FalyxParsers(
root=parser,
subparsers=subparsers,
run=run_parser,
run_all=run_all_parser,
preview=preview_parser,
list=list_parser,
version=version_parser,
)

105
falyx/parser/signature.py Normal file
View File

@ -0,0 +1,105 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Provides utilities for introspecting Python callables and extracting argument
metadata compatible with Falyx's `CommandArgumentParser`.
This module is primarily used to auto-generate command argument definitions from
function signatures, enabling seamless integration of plain functions into the
Falyx CLI with minimal boilerplate.
Functions:
- infer_args_from_func: Generate a list of argument definitions based on a function's signature.
"""
import inspect
from typing import Any, Callable
from falyx.logger import logger
def infer_args_from_func(
func: Callable[[Any], Any] | None,
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
"""
Infer CLI-style argument definitions from a function signature.
This utility inspects the parameters of a function and returns a list of dictionaries,
each of which can be passed to `CommandArgumentParser.add_argument()`.
Args:
func (Callable | None): The function to inspect.
arg_metadata (dict | None): Optional metadata overrides for help text, type hints,
choices, and suggestions for each parameter.
Returns:
list[dict[str, Any]]: A list of argument definitions inferred from the function.
"""
if not callable(func):
logger.debug("Provided argument is not callable: %s", func)
return []
arg_metadata = arg_metadata or {}
signature = inspect.signature(func)
arg_defs = []
for name, param in signature.parameters.items():
raw_metadata = arg_metadata.get(name, {})
metadata = (
{"help": raw_metadata} if isinstance(raw_metadata, str) else raw_metadata
)
if param.kind not in (
inspect.Parameter.POSITIONAL_ONLY,
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.KEYWORD_ONLY,
):
continue
if metadata.get("type"):
arg_type = metadata["type"]
else:
arg_type = (
param.annotation
if param.annotation is not inspect.Parameter.empty
else str
)
if isinstance(arg_type, str):
arg_type = str
default = param.default if param.default is not inspect.Parameter.empty else None
is_required = param.default is inspect.Parameter.empty
if is_required:
flags = [f"{name.replace('_', '-')}"]
else:
flags = [f"--{name.replace('_', '-')}"]
action = "store"
nargs: int | str | None = None
if arg_type is bool:
if param.default is False:
action = "store_true"
default = None
elif param.default is True:
action = "store_false"
default = None
if arg_type is list:
action = "append"
if is_required:
nargs = "+"
else:
nargs = "*"
arg_defs.append(
{
"flags": flags,
"dest": name,
"type": arg_type,
"default": default,
"required": is_required,
"nargs": nargs,
"action": action,
"help": metadata.get("help", ""),
"choices": metadata.get("choices"),
"suggestions": metadata.get("suggestions"),
}
)
return arg_defs

164
falyx/parser/utils.py Normal file
View File

@ -0,0 +1,164 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Contains value coercion and signature comparison utilities for Falyx argument parsing.
This module provides type coercion functions for converting string input into expected
Python types, including `Enum`, `bool`, `datetime`, and `Literal`. It also supports
checking whether multiple actions share identical inferred argument definitions.
Functions:
- coerce_bool: Convert a string to a boolean.
- coerce_enum: Convert a string or raw value to an Enum instance.
- coerce_value: General-purpose coercion to a target type (including nested unions, enums, etc.).
- same_argument_definitions: Check if multiple callables share the same argument structure.
"""
import types
from datetime import datetime
from enum import EnumMeta
from typing import Any, Literal, Union, get_args, get_origin
from dateutil import parser as date_parser
from falyx.action.base_action import BaseAction
from falyx.logger import logger
from falyx.parser.signature import infer_args_from_func
def coerce_bool(value: str) -> bool:
"""
Convert a string to a boolean.
Accepts various truthy and falsy representations such as 'true', 'yes', '0', 'off', etc.
Args:
value (str): The input string or boolean.
Returns:
bool: Parsed boolean result.
"""
if isinstance(value, bool):
return value
value = value.strip().lower()
if value in {"true", "t", "1", "yes", "on"}:
return True
elif value in {"false", "f", "0", "no", "off"}:
return False
return bool(value)
def coerce_enum(value: Any, enum_type: EnumMeta) -> Any:
"""
Convert a raw value or string to an Enum instance.
Tries to resolve by name, value, or coerced base type.
Args:
value (Any): The input value to convert.
enum_type (EnumMeta): The target Enum class.
Returns:
Enum: The corresponding Enum instance.
Raises:
ValueError: If the value cannot be resolved to a valid Enum member.
"""
if isinstance(value, enum_type):
return value
if isinstance(value, str):
try:
return enum_type[value]
except KeyError:
pass
base_type = type(next(iter(enum_type)).value)
try:
coerced_value = base_type(value)
return enum_type(coerced_value)
except (ValueError, TypeError):
values = [str(enum.value) for enum in enum_type]
raise ValueError(f"'{value}' should be one of {{{', '.join(values)}}}") from None
def coerce_value(value: str, target_type: type) -> Any:
"""
Attempt to convert a string to the given target type.
Handles complex typing constructs such as Union, Literal, Enum, and datetime.
Args:
value (str): The input string to convert.
target_type (type): The desired type.
Returns:
Any: The coerced value.
Raises:
ValueError: If conversion fails or the value is invalid.
"""
origin = get_origin(target_type)
args = get_args(target_type)
if origin is Literal:
if value not in args:
raise ValueError(
f"Value '{value}' is not a valid literal for type {target_type}"
)
return value
if isinstance(target_type, types.UnionType) or get_origin(target_type) is Union:
for arg in args:
try:
return coerce_value(value, arg)
except Exception:
continue
raise ValueError(f"Value '{value}' could not be coerced to any of {args}")
if isinstance(target_type, EnumMeta):
return coerce_enum(value, target_type)
if target_type is bool:
return coerce_bool(value)
if target_type is datetime:
try:
return date_parser.parse(value)
except ValueError as e:
raise ValueError(f"Value '{value}' could not be parsed as a datetime") from e
return target_type(value)
def same_argument_definitions(
actions: list[Any],
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
) -> list[dict[str, Any]] | None:
"""
Determine if multiple callables resolve to the same argument definitions.
This is used to infer whether actions in an ActionGroup or ProcessPool can share
a unified argument parser.
Args:
actions (list[Any]): A list of BaseAction instances or callables.
arg_metadata (dict | None): Optional overrides for argument help or type info.
Returns:
list[dict[str, Any]] | None: The shared argument definitions if consistent, else None.
"""
arg_sets = []
for action in actions:
if isinstance(action, BaseAction):
infer_target, _ = action.get_infer_target()
arg_defs = infer_args_from_func(infer_target, arg_metadata)
elif callable(action):
arg_defs = infer_args_from_func(action, arg_metadata)
else:
logger.debug("Auto args unsupported for action: %s", action)
return None
arg_sets.append(arg_defs)
first = arg_sets[0]
if all(arg_set == first for arg_set in arg_sets[1:]):
return first
return None

View File

@ -1,21 +0,0 @@
"""
Falyx CLI Framework
Copyright (c) 2025 rtj.dev LLC.
Licensed under the MIT License. See LICENSE file for details.
"""
from .argparse import Argument, ArgumentAction, CommandArgumentParser
from .parsers import FalyxParsers, get_arg_parsers
from .signature import infer_args_from_func
from .utils import same_argument_definitions
__all__ = [
"Argument",
"ArgumentAction",
"CommandArgumentParser",
"get_arg_parsers",
"FalyxParsers",
"infer_args_from_func",
"same_argument_definitions",
]

View File

@ -1,756 +0,0 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
from copy import deepcopy
from dataclasses import dataclass
from enum import Enum
from typing import Any, Iterable
from rich.console import Console
from rich.markup import escape
from rich.text import Text
from falyx.exceptions import CommandArgumentError
from falyx.signals import HelpSignal
class ArgumentAction(Enum):
"""Defines the action to be taken when the argument is encountered."""
STORE = "store"
STORE_TRUE = "store_true"
STORE_FALSE = "store_false"
APPEND = "append"
EXTEND = "extend"
COUNT = "count"
HELP = "help"
@dataclass
class Argument:
"""Represents a command-line argument."""
flags: list[str]
dest: str # Destination name for the argument
action: ArgumentAction = (
ArgumentAction.STORE
) # Action to be taken when the argument is encountered
type: Any = str # Type of the argument (e.g., str, int, float) or callable
default: Any = None # Default value if the argument is not provided
choices: list[str] | None = None # List of valid choices for the argument
required: bool = False # True if the argument is required
help: str = "" # Help text for the argument
nargs: int | str = 1 # int, '?', '*', '+'
positional: bool = False # True if no leading - or -- in flags
def get_positional_text(self) -> str:
"""Get the positional text for the argument."""
text = ""
if self.positional:
if self.choices:
text = f"{{{','.join([str(choice) for choice in self.choices])}}}"
else:
text = self.dest
return text
def get_choice_text(self) -> str:
"""Get the choice text for the argument."""
choice_text = ""
if self.choices:
choice_text = f"{{{','.join([str(choice) for choice in self.choices])}}}"
elif (
self.action
in (
ArgumentAction.STORE,
ArgumentAction.APPEND,
ArgumentAction.EXTEND,
)
and not self.positional
):
choice_text = self.dest.upper()
elif isinstance(self.nargs, str):
choice_text = self.dest
if self.nargs == "?":
choice_text = f"[{choice_text}]"
elif self.nargs == "*":
choice_text = f"[{choice_text} ...]"
elif self.nargs == "+":
choice_text = f"{choice_text} [{choice_text} ...]"
return choice_text
def __eq__(self, other: object) -> bool:
if not isinstance(other, Argument):
return False
return (
self.flags == other.flags
and self.dest == other.dest
and self.action == other.action
and self.type == other.type
and self.choices == other.choices
and self.required == other.required
and self.nargs == other.nargs
and self.positional == other.positional
)
def __hash__(self) -> int:
return hash(
(
tuple(self.flags),
self.dest,
self.action,
self.type,
tuple(self.choices or []),
self.required,
self.nargs,
self.positional,
)
)
class CommandArgumentParser:
"""
Custom argument parser for Falyx Commands.
It is used to create a command-line interface for Falyx
commands, allowing users to specify options and arguments
when executing commands.
It is not intended to be a full-featured replacement for
argparse, but rather a lightweight alternative for specific use
cases within the Falyx framework.
Features:
- Customizable argument parsing.
- Type coercion for arguments.
- Support for positional and keyword arguments.
- Support for default values.
- Support for boolean flags.
- Exception handling for invalid arguments.
- Render Help using Rich library.
"""
def __init__(
self,
command_key: str = "",
command_description: str = "",
command_style: str = "bold",
help_text: str = "",
help_epilogue: str = "",
aliases: list[str] | None = None,
) -> None:
"""Initialize the CommandArgumentParser."""
self.command_key: str = command_key
self.command_description: str = command_description
self.command_style: str = command_style
self.help_text: str = help_text
self.help_epilogue: str = help_epilogue
self.aliases: list[str] = aliases or []
self._arguments: list[Argument] = []
self._positional: list[Argument] = []
self._keyword: list[Argument] = []
self._flag_map: dict[str, Argument] = {}
self._dest_set: set[str] = set()
self._add_help()
self.console = Console(color_system="auto")
def _add_help(self):
"""Add help argument to the parser."""
self.add_argument(
"-h",
"--help",
action=ArgumentAction.HELP,
help="Show this help message.",
dest="help",
)
def _is_positional(self, flags: tuple[str, ...]) -> bool:
"""Check if the flags are positional."""
positional = False
if any(not flag.startswith("-") for flag in flags):
positional = True
if positional and len(flags) > 1:
raise CommandArgumentError("Positional arguments cannot have multiple flags")
return positional
def _get_dest_from_flags(
self, flags: tuple[str, ...], dest: str | None
) -> str | None:
"""Convert flags to a destination name."""
if dest:
if not dest.replace("_", "").isalnum():
raise CommandArgumentError(
"dest must be a valid identifier (letters, digits, and underscores only)"
)
if dest[0].isdigit():
raise CommandArgumentError("dest must not start with a digit")
return dest
dest = None
for flag in flags:
if flag.startswith("--"):
dest = flag.lstrip("-").replace("-", "_").lower()
break
elif flag.startswith("-"):
dest = flag.lstrip("-").replace("-", "_").lower()
else:
dest = flag.replace("-", "_").lower()
assert dest is not None, "dest should not be None"
if not dest.replace("_", "").isalnum():
raise CommandArgumentError(
"dest must be a valid identifier (letters, digits, and underscores only)"
)
if dest[0].isdigit():
raise CommandArgumentError("dest must not start with a digit")
return dest
def _determine_required(
self, required: bool, positional: bool, nargs: int | str
) -> bool:
"""Determine if the argument is required."""
if required:
return True
if positional:
if isinstance(nargs, int):
return nargs > 0
elif isinstance(nargs, str):
if nargs in ("+"):
return True
elif nargs in ("*", "?"):
return False
else:
raise CommandArgumentError(f"Invalid nargs value: {nargs}")
return required
def _validate_nargs(self, nargs: int | str) -> int | str:
allowed_nargs = ("?", "*", "+")
if isinstance(nargs, int):
if nargs <= 0:
raise CommandArgumentError("nargs must be a positive integer")
elif isinstance(nargs, str):
if nargs not in allowed_nargs:
raise CommandArgumentError(f"Invalid nargs value: {nargs}")
else:
raise CommandArgumentError(f"nargs must be an int or one of {allowed_nargs}")
return nargs
def _normalize_choices(self, choices: Iterable, expected_type: Any) -> list[Any]:
if choices is not None:
if isinstance(choices, dict):
raise CommandArgumentError("choices cannot be a dict")
try:
choices = list(choices)
except TypeError:
raise CommandArgumentError(
"choices must be iterable (like list, tuple, or set)"
)
else:
choices = []
for choice in choices:
if not isinstance(choice, expected_type):
try:
expected_type(choice)
except Exception:
raise CommandArgumentError(
f"Invalid choice {choice!r}: not coercible to {expected_type.__name__}"
)
return choices
def _validate_default_type(
self, default: Any, expected_type: type, dest: str
) -> None:
"""Validate the default value type."""
if default is not None and not isinstance(default, expected_type):
try:
expected_type(default)
except Exception:
raise CommandArgumentError(
f"Default value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__}"
)
def _validate_default_list_type(
self, default: list[Any], expected_type: type, dest: str
) -> None:
if isinstance(default, list):
for item in default:
if not isinstance(item, expected_type):
try:
expected_type(item)
except Exception:
raise CommandArgumentError(
f"Default list value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__}"
)
def _resolve_default(
self, action: ArgumentAction, default: Any, nargs: str | int
) -> Any:
"""Get the default value for the argument."""
if default is None:
if action == ArgumentAction.STORE_TRUE:
return False
elif action == ArgumentAction.STORE_FALSE:
return True
elif action == ArgumentAction.COUNT:
return 0
elif action in (ArgumentAction.APPEND, ArgumentAction.EXTEND):
return []
elif nargs in ("+", "*"):
return []
else:
return None
return default
def _validate_flags(self, flags: tuple[str, ...]) -> None:
"""Validate the flags provided for the argument."""
if not flags:
raise CommandArgumentError("No flags provided")
for flag in flags:
if not isinstance(flag, str):
raise CommandArgumentError(f"Flag '{flag}' must be a string")
if flag.startswith("--") and len(flag) < 3:
raise CommandArgumentError(
f"Flag '{flag}' must be at least 3 characters long"
)
if flag.startswith("-") and not flag.startswith("--") and len(flag) > 2:
raise CommandArgumentError(
f"Flag '{flag}' must be a single character or start with '--'"
)
def add_argument(self, *flags, **kwargs):
"""Add an argument to the parser.
Args:
name or flags: Either a name or prefixed flags (e.g. 'faylx', '-f', '--falyx').
action: The action to be taken when the argument is encountered.
nargs: The number of arguments expected.
default: The default value if the argument is not provided.
type: The type to which the command-line argument should be converted.
choices: A container of the allowable values for the argument.
required: Whether or not the argument is required.
help: A brief description of the argument.
dest: The name of the attribute to be added to the object returned by parse_args().
"""
self._validate_flags(flags)
positional = self._is_positional(flags)
dest = self._get_dest_from_flags(flags, kwargs.get("dest"))
if dest in self._dest_set:
raise CommandArgumentError(
f"Destination '{dest}' is already defined.\n"
"Merging multiple arguments into the same dest (e.g. positional + flagged) "
"is not supported. Define a unique 'dest' for each argument."
)
self._dest_set.add(dest)
action = kwargs.get("action", ArgumentAction.STORE)
if not isinstance(action, ArgumentAction):
try:
action = ArgumentAction(action)
except ValueError:
raise CommandArgumentError(
f"Invalid action '{action}' is not a valid ArgumentAction"
)
flags = list(flags)
nargs = self._validate_nargs(kwargs.get("nargs", 1))
default = self._resolve_default(action, kwargs.get("default"), nargs)
expected_type = kwargs.get("type", str)
if (
action in (ArgumentAction.STORE, ArgumentAction.APPEND, ArgumentAction.EXTEND)
and default is not None
):
if isinstance(default, list):
self._validate_default_list_type(default, expected_type, dest)
else:
self._validate_default_type(default, expected_type, dest)
choices = self._normalize_choices(kwargs.get("choices"), expected_type)
if default is not None and choices and default not in choices:
raise CommandArgumentError(
f"Default value '{default}' not in allowed choices: {choices}"
)
required = self._determine_required(
kwargs.get("required", False), positional, nargs
)
argument = Argument(
flags=flags,
dest=dest,
action=action,
type=expected_type,
default=default,
choices=choices,
required=required,
help=kwargs.get("help", ""),
nargs=nargs,
positional=positional,
)
for flag in flags:
if flag in self._flag_map:
existing = self._flag_map[flag]
raise CommandArgumentError(
f"Flag '{flag}' is already used by argument '{existing.dest}'"
)
self._flag_map[flag] = argument
self._arguments.append(argument)
if positional:
self._positional.append(argument)
else:
self._keyword.append(argument)
def get_argument(self, dest: str) -> Argument | None:
return next((a for a in self._arguments if a.dest == dest), None)
def to_definition_list(self) -> list[dict[str, Any]]:
defs = []
for arg in self._arguments:
defs.append(
{
"flags": arg.flags,
"dest": arg.dest,
"action": arg.action,
"type": arg.type,
"choices": arg.choices,
"required": arg.required,
"nargs": arg.nargs,
"positional": arg.positional,
}
)
return defs
def _consume_nargs(
self, args: list[str], start: int, spec: Argument
) -> tuple[list[str], int]:
values = []
i = start
if isinstance(spec.nargs, int):
# assert i + spec.nargs <= len(
# args
# ), "Not enough arguments provided: shouldn't happen"
values = args[i : i + spec.nargs]
return values, i + spec.nargs
elif spec.nargs == "+":
if i >= len(args):
raise CommandArgumentError(
f"Expected at least one value for '{spec.dest}'"
)
while i < len(args) and not args[i].startswith("-"):
values.append(args[i])
i += 1
assert values, "Expected at least one value for '+' nargs: shouldn't happen"
return values, i
elif spec.nargs == "*":
while i < len(args) and not args[i].startswith("-"):
values.append(args[i])
i += 1
return values, i
elif spec.nargs == "?":
if i < len(args) and not args[i].startswith("-"):
return [args[i]], i + 1
return [], i
else:
assert False, "Invalid nargs value: shouldn't happen"
def _consume_all_positional_args(
self,
args: list[str],
result: dict[str, Any],
positional_args: list[Argument],
consumed_positional_indicies: set[int],
) -> int:
remaining_positional_args = [
(j, spec)
for j, spec in enumerate(positional_args)
if j not in consumed_positional_indicies
]
i = 0
for j, spec in remaining_positional_args:
# estimate how many args the remaining specs might need
is_last = j == len(positional_args) - 1
remaining = len(args) - i
min_required = 0
for next_spec in positional_args[j + 1 :]:
if isinstance(next_spec.nargs, int):
min_required += next_spec.nargs
elif next_spec.nargs == "+":
min_required += 1
elif next_spec.nargs == "?":
min_required += 0
elif next_spec.nargs == "*":
min_required += 0
else:
assert False, "Invalid nargs value: shouldn't happen"
slice_args = args[i:] if is_last else args[i : i + (remaining - min_required)]
values, new_i = self._consume_nargs(slice_args, 0, spec)
i += new_i
try:
typed = [spec.type(v) for v in values]
except Exception:
raise CommandArgumentError(
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
)
if spec.action == ArgumentAction.APPEND:
assert result.get(spec.dest) is not None, "dest should not be None"
if spec.nargs in (None, 1):
result[spec.dest].append(typed[0])
else:
result[spec.dest].append(typed)
elif spec.action == ArgumentAction.EXTEND:
assert result.get(spec.dest) is not None, "dest should not be None"
result[spec.dest].extend(typed)
elif spec.nargs in (None, 1, "?"):
result[spec.dest] = typed[0] if len(typed) == 1 else typed
else:
result[spec.dest] = typed
if spec.nargs not in ("*", "+"):
consumed_positional_indicies.add(j)
if i < len(args):
raise CommandArgumentError(f"Unexpected positional argument: {args[i:]}")
return i
def parse_args(
self, args: list[str] | None = None, from_validate: bool = False
) -> dict[str, Any]:
"""Parse Falyx Command arguments."""
if args is None:
args = []
result = {arg.dest: deepcopy(arg.default) for arg in self._arguments}
positional_args = [arg for arg in self._arguments if arg.positional]
consumed_positional_indices: set[int] = set()
consumed_indices: set[int] = set()
i = 0
while i < len(args):
token = args[i]
if token in self._flag_map:
spec = self._flag_map[token]
action = spec.action
if action == ArgumentAction.HELP:
if not from_validate:
self.render_help()
raise HelpSignal()
elif action == ArgumentAction.STORE_TRUE:
result[spec.dest] = True
consumed_indices.add(i)
i += 1
elif action == ArgumentAction.STORE_FALSE:
result[spec.dest] = False
consumed_indices.add(i)
i += 1
elif action == ArgumentAction.COUNT:
result[spec.dest] = result.get(spec.dest, 0) + 1
consumed_indices.add(i)
i += 1
elif action == ArgumentAction.APPEND:
assert result.get(spec.dest) is not None, "dest should not be None"
values, new_i = self._consume_nargs(args, i + 1, spec)
try:
typed_values = [spec.type(value) for value in values]
except ValueError:
raise CommandArgumentError(
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
)
if spec.nargs in (None, 1):
try:
result[spec.dest].append(spec.type(values[0]))
except ValueError:
raise CommandArgumentError(
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
)
else:
result[spec.dest].append(typed_values)
consumed_indices.update(range(i, new_i))
i = new_i
elif action == ArgumentAction.EXTEND:
assert result.get(spec.dest) is not None, "dest should not be None"
values, new_i = self._consume_nargs(args, i + 1, spec)
try:
typed_values = [spec.type(value) for value in values]
except ValueError:
raise CommandArgumentError(
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
)
result[spec.dest].extend(typed_values)
consumed_indices.update(range(i, new_i))
i = new_i
else:
values, new_i = self._consume_nargs(args, i + 1, spec)
try:
typed_values = [spec.type(v) for v in values]
except ValueError:
raise CommandArgumentError(
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
)
if (
spec.nargs in (None, 1, "?")
and spec.action != ArgumentAction.APPEND
):
result[spec.dest] = (
typed_values[0] if len(typed_values) == 1 else typed_values
)
else:
result[spec.dest] = typed_values
consumed_indices.update(range(i, new_i))
i = new_i
else:
# Get the next flagged argument index if it exists
next_flagged_index = -1
for index, arg in enumerate(args[i:], start=i):
if arg.startswith("-"):
next_flagged_index = index
break
if next_flagged_index == -1:
next_flagged_index = len(args)
args_consumed = self._consume_all_positional_args(
args[i:next_flagged_index],
result,
positional_args,
consumed_positional_indices,
)
i += args_consumed
# Required validation
for spec in self._arguments:
if spec.dest == "help":
continue
if spec.required and not result.get(spec.dest):
raise CommandArgumentError(f"Missing required argument: {spec.dest}")
if spec.choices and result.get(spec.dest) not in spec.choices:
raise CommandArgumentError(
f"Invalid value for {spec.dest}: must be one of {spec.choices}"
)
if isinstance(spec.nargs, int) and spec.nargs > 1:
if not isinstance(result.get(spec.dest), list):
raise CommandArgumentError(
f"Invalid value for {spec.dest}: expected a list"
)
if spec.action == ArgumentAction.APPEND:
if not isinstance(result[spec.dest], list):
raise CommandArgumentError(
f"Invalid value for {spec.dest}: expected a list"
)
for group in result[spec.dest]:
if len(group) % spec.nargs != 0:
raise CommandArgumentError(
f"Invalid number of values for {spec.dest}: expected a multiple of {spec.nargs}"
)
elif spec.action == ArgumentAction.EXTEND:
if not isinstance(result[spec.dest], list):
raise CommandArgumentError(
f"Invalid value for {spec.dest}: expected a list"
)
if len(result[spec.dest]) % spec.nargs != 0:
raise CommandArgumentError(
f"Invalid number of values for {spec.dest}: expected a multiple of {spec.nargs}"
)
elif len(result[spec.dest]) != spec.nargs:
raise CommandArgumentError(
f"Invalid number of values for {spec.dest}: expected {spec.nargs}, got {len(result[spec.dest])}"
)
result.pop("help", None)
return result
def parse_args_split(
self, args: list[str], from_validate: bool = False
) -> tuple[tuple[Any, ...], dict[str, Any]]:
"""
Returns:
tuple[args, kwargs] - Positional arguments in defined order,
followed by keyword argument mapping.
"""
parsed = self.parse_args(args, from_validate)
args_list = []
kwargs_dict = {}
for arg in self._arguments:
if arg.dest == "help":
continue
if arg.positional:
args_list.append(parsed[arg.dest])
else:
kwargs_dict[arg.dest] = parsed[arg.dest]
return tuple(args_list), kwargs_dict
def render_help(self) -> None:
# Options
# Add all keyword arguments to the options list
options_list = []
for arg in self._keyword:
choice_text = arg.get_choice_text()
if choice_text:
options_list.extend([f"[{arg.flags[0]} {choice_text}]"])
else:
options_list.extend([f"[{arg.flags[0]}]"])
# Add positional arguments to the options list
for arg in self._positional:
choice_text = arg.get_choice_text()
if isinstance(arg.nargs, int):
choice_text = " ".join([choice_text] * arg.nargs)
options_list.append(escape(choice_text))
options_text = " ".join(options_list)
command_keys = " | ".join(
[f"[{self.command_style}]{self.command_key}[/{self.command_style}]"]
+ [
f"[{self.command_style}]{alias}[/{self.command_style}]"
for alias in self.aliases
]
)
usage = f"usage: {command_keys} {options_text}"
self.console.print(f"[bold]{usage}[/bold]\n")
# Description
if self.help_text:
self.console.print(self.help_text + "\n")
# Arguments
if self._arguments:
if self._positional:
self.console.print("[bold]positional:[/bold]")
for arg in self._positional:
flags = arg.get_positional_text()
arg_line = Text(f" {flags:<30} ")
help_text = arg.help or ""
arg_line.append(help_text)
self.console.print(arg_line)
self.console.print("[bold]options:[/bold]")
for arg in self._keyword:
flags = ", ".join(arg.flags)
flags_choice = f"{flags} {arg.get_choice_text()}"
arg_line = Text(f" {flags_choice:<30} ")
help_text = arg.help or ""
arg_line.append(help_text)
self.console.print(arg_line)
# Epilogue
if self.help_epilogue:
self.console.print("\n" + self.help_epilogue, style="dim")
def __eq__(self, other: object) -> bool:
if not isinstance(other, CommandArgumentParser):
return False
def sorted_args(parser):
return sorted(parser._arguments, key=lambda a: a.dest)
return sorted_args(self) == sorted_args(other)
def __hash__(self) -> int:
return hash(tuple(sorted(self._arguments, key=lambda a: a.dest)))
def __str__(self) -> str:
positional = sum(arg.positional for arg in self._arguments)
required = sum(arg.required for arg in self._arguments)
return (
f"CommandArgumentParser(args={len(self._arguments)}, "
f"flags={len(self._flag_map)}, dests={len(self._dest_set)}, "
f"required={required}, positional={positional})"
)
def __repr__(self) -> str:
return str(self)

View File

@ -1,179 +0,0 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""parsers.py
This module contains the argument parsers used for the Falyx CLI.
"""
from argparse import REMAINDER, ArgumentParser, Namespace, _SubParsersAction
from dataclasses import asdict, dataclass
from typing import Any, Sequence
@dataclass
class FalyxParsers:
"""Defines the argument parsers for the Falyx CLI."""
root: ArgumentParser
subparsers: _SubParsersAction
run: ArgumentParser
run_all: ArgumentParser
preview: ArgumentParser
list: ArgumentParser
version: ArgumentParser
def parse_args(self, args: Sequence[str] | None = None) -> Namespace:
"""Parse the command line arguments."""
return self.root.parse_args(args)
def as_dict(self) -> dict[str, ArgumentParser]:
"""Convert the FalyxParsers instance to a dictionary."""
return asdict(self)
def get_parser(self, name: str) -> ArgumentParser | None:
"""Get the parser by name."""
return self.as_dict().get(name)
def get_arg_parsers(
prog: str | None = "falyx",
usage: str | None = None,
description: str | None = "Falyx CLI - Run structured async command workflows.",
epilog: (
str | None
) = "Tip: Use 'falyx run ?[COMMAND]' to preview any command from the CLI.",
parents: Sequence[ArgumentParser] | None = None,
prefix_chars: str = "-",
fromfile_prefix_chars: str | None = None,
argument_default: Any = None,
conflict_handler: str = "error",
add_help: bool = True,
allow_abbrev: bool = True,
exit_on_error: bool = True,
) -> FalyxParsers:
"""Returns the argument parser for the CLI."""
parser = ArgumentParser(
prog=prog,
usage=usage,
description=description,
epilog=epilog,
parents=parents if parents else [],
prefix_chars=prefix_chars,
fromfile_prefix_chars=fromfile_prefix_chars,
argument_default=argument_default,
conflict_handler=conflict_handler,
add_help=add_help,
allow_abbrev=allow_abbrev,
exit_on_error=exit_on_error,
)
parser.add_argument(
"--never-prompt",
action="store_true",
help="Run in non-interactive mode with all prompts bypassed.",
)
parser.add_argument(
"-v", "--verbose", action="store_true", help="Enable debug logging for Falyx."
)
parser.add_argument(
"--debug-hooks",
action="store_true",
help="Enable default lifecycle debug logging",
)
parser.add_argument("--version", action="store_true", help="Show Falyx version")
subparsers = parser.add_subparsers(dest="command")
run_parser = subparsers.add_parser("run", help="Run a specific command")
run_parser.add_argument("name", help="Key, alias, or description of the command")
run_parser.add_argument(
"--summary",
action="store_true",
help="Print an execution summary after command completes",
)
run_parser.add_argument(
"--retries", type=int, help="Number of retries on failure", default=0
)
run_parser.add_argument(
"--retry-delay",
type=float,
help="Initial delay between retries in (seconds)",
default=0,
)
run_parser.add_argument(
"--retry-backoff", type=float, help="Backoff factor for retries", default=0
)
run_group = run_parser.add_mutually_exclusive_group(required=False)
run_group.add_argument(
"-c",
"--confirm",
dest="force_confirm",
action="store_true",
help="Force confirmation prompts",
)
run_group.add_argument(
"-s",
"--skip-confirm",
dest="skip_confirm",
action="store_true",
help="Skip confirmation prompts",
)
run_group.add_argument(
"command_args",
nargs=REMAINDER,
help="Arguments to pass to the command (if applicable)",
)
run_all_parser = subparsers.add_parser(
"run-all", help="Run all commands with a given tag"
)
run_all_parser.add_argument("-t", "--tag", required=True, help="Tag to match")
run_all_parser.add_argument(
"--summary",
action="store_true",
help="Print a summary after all tagged commands run",
)
run_all_parser.add_argument(
"--retries", type=int, help="Number of retries on failure", default=0
)
run_all_parser.add_argument(
"--retry-delay",
type=float,
help="Initial delay between retries in (seconds)",
default=0,
)
run_all_parser.add_argument(
"--retry-backoff", type=float, help="Backoff factor for retries", default=0
)
run_all_group = run_all_parser.add_mutually_exclusive_group(required=False)
run_all_group.add_argument(
"-c",
"--confirm",
dest="force_confirm",
action="store_true",
help="Force confirmation prompts",
)
run_all_group.add_argument(
"-s",
"--skip-confirm",
dest="skip_confirm",
action="store_true",
help="Skip confirmation prompts",
)
preview_parser = subparsers.add_parser(
"preview", help="Preview a command without running it"
)
preview_parser.add_argument("name", help="Key, alias, or description of the command")
list_parser = subparsers.add_parser(
"list", help="List all available commands with tags"
)
version_parser = subparsers.add_parser("version", help="Show the Falyx version")
return FalyxParsers(
root=parser,
subparsers=subparsers,
run=run_parser,
run_all=run_all_parser,
preview=preview_parser,
list=list_parser,
version=version_parser,
)

View File

@ -1,71 +0,0 @@
import inspect
from typing import Any, Callable
from falyx import logger
def infer_args_from_func(
func: Callable[[Any], Any],
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
"""
Infer argument definitions from a callable's signature.
Returns a list of kwargs suitable for CommandArgumentParser.add_argument.
"""
arg_metadata = arg_metadata or {}
signature = inspect.signature(func)
arg_defs = []
for name, param in signature.parameters.items():
raw_metadata = arg_metadata.get(name, {})
metadata = (
{"help": raw_metadata} if isinstance(raw_metadata, str) else raw_metadata
)
if param.kind not in (
inspect.Parameter.POSITIONAL_ONLY,
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.KEYWORD_ONLY,
):
continue
arg_type = (
param.annotation if param.annotation is not inspect.Parameter.empty else str
)
default = param.default if param.default is not inspect.Parameter.empty else None
is_required = param.default is inspect.Parameter.empty
if is_required:
flags = [f"{name.replace('_', '-')}"]
else:
flags = [f"--{name.replace('_', '-')}"]
action = "store"
nargs: int | str = 1
if arg_type is bool:
if param.default is False:
action = "store_true"
else:
action = "store_false"
if arg_type is list:
action = "append"
if is_required:
nargs = "+"
else:
nargs = "*"
arg_defs.append(
{
"flags": flags,
"dest": name,
"type": arg_type,
"default": default,
"required": is_required,
"nargs": nargs,
"action": action,
"help": metadata.get("help", ""),
"choices": metadata.get("choices"),
}
)
return arg_defs

View File

@ -1,33 +0,0 @@
from typing import Any
from falyx import logger
from falyx.action.action import Action, ChainedAction, ProcessAction
from falyx.parsers.signature import infer_args_from_func
def same_argument_definitions(
actions: list[Any],
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
) -> list[dict[str, Any]] | None:
arg_sets = []
for action in actions:
if isinstance(action, (Action, ProcessAction)):
arg_defs = infer_args_from_func(action.action, arg_metadata)
elif isinstance(action, ChainedAction):
if action.actions:
action = action.actions[0]
if isinstance(action, Action):
arg_defs = infer_args_from_func(action.action, arg_metadata)
elif callable(action):
arg_defs = infer_args_from_func(action, arg_metadata)
elif callable(action):
arg_defs = infer_args_from_func(action, arg_metadata)
else:
logger.debug("Auto args unsupported for action: %s", action)
return None
arg_sets.append(arg_defs)
first = arg_sets[0]
if all(arg_set == first for arg_set in arg_sets[1:]):
return first
return None

View File

@ -1,11 +1,24 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""prompt_utils.py"""
"""
Utilities for user interaction prompts in the Falyx CLI framework.
Provides asynchronous confirmation dialogs and helper logic to determine
whether a user should be prompted based on command-line options.
Includes:
- `should_prompt_user()` for conditional prompt logic.
- `confirm_async()` for interactive yes/no confirmation.
"""
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import (
AnyFormattedText,
FormattedText,
StyleAndTextTuples,
merge_formatted_text,
)
from rich.console import Console
from rich.text import Text
from falyx.options_manager import OptionsManager
from falyx.themes import OneColors
@ -46,3 +59,31 @@ async def confirm_async(
validator=yes_no_validator(),
)
return answer.upper() == "Y"
def rich_text_to_prompt_text(text: Text | str | StyleAndTextTuples) -> StyleAndTextTuples:
"""
Convert a Rich Text object to a list of (style, text) tuples
compatible with prompt_toolkit.
"""
if isinstance(text, list):
if all(isinstance(pair, tuple) and len(pair) == 2 for pair in text):
return text
raise TypeError("Expected list of (style, text) tuples")
if isinstance(text, str):
text = Text.from_markup(text)
if not isinstance(text, Text):
raise TypeError("Expected str, rich.text.Text, or list of (style, text) tuples")
console = Console(color_system=None, file=None, width=999, legacy_windows=False)
segments = text.render(console)
prompt_fragments: StyleAndTextTuples = []
for segment in segments:
style = segment.style or ""
string = segment.text
if string:
prompt_fragments.append((str(style), string))
return prompt_fragments

View File

@ -1,15 +1,30 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""protocols.py"""
"""
Defines structural protocols for advanced Falyx features.
These runtime-checkable `Protocol` classes specify the expected interfaces for:
- Factories that asynchronously return actions
- Argument parsers used in dynamic command execution
Used to support type-safe extensibility and plugin-like behavior without requiring
explicit base classes.
Protocols:
- ActionFactoryProtocol: Async callable that returns a coroutine yielding a BaseAction.
- ArgParserProtocol: Callable that accepts CLI-style args and returns (args, kwargs) tuple.
"""
from __future__ import annotations
from typing import Any, Awaitable, Protocol, runtime_checkable
from typing import Any, Awaitable, Callable, Protocol, runtime_checkable
from falyx.action.action import BaseAction
from falyx.action.base_action import BaseAction
@runtime_checkable
class ActionFactoryProtocol(Protocol):
async def __call__(self, *args: Any, **kwargs: Any) -> Awaitable[BaseAction]: ...
async def __call__(
self, *args: Any, **kwargs: Any
) -> Callable[..., Awaitable[BaseAction]]: ...
@runtime_checkable

View File

@ -1,5 +1,23 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""retry.py"""
"""
Implements retry logic for Falyx Actions using configurable retry policies.
This module defines:
- `RetryPolicy`: A configurable model controlling retry behavior (delay, backoff, jitter).
- `RetryHandler`: A hook-compatible class that manages retry attempts for failed actions.
Used to automatically retry transient failures in leaf-level `Action` objects
when marked as retryable. Integrates with the Falyx hook lifecycle via `on_error`.
Supports:
- Exponential backoff with optional jitter
- Manual or declarative policy control
- Per-action retry logging and recovery
Example:
handler = RetryHandler(RetryPolicy(max_retries=5, delay=1.0))
action.hooks.register(HookType.ON_ERROR, handler.retry_on_error)
"""
from __future__ import annotations
import asyncio
@ -12,7 +30,28 @@ from falyx.logger import logger
class RetryPolicy(BaseModel):
"""RetryPolicy"""
"""
Defines a retry strategy for Falyx `Action` objects.
This model controls whether an action should be retried on failure, and how:
- `max_retries`: Maximum number of retry attempts.
- `delay`: Initial wait time before the first retry (in seconds).
- `backoff`: Multiplier applied to the delay after each failure (≥ 1.0).
- `jitter`: Optional random noise added/subtracted from delay to reduce thundering herd issues.
- `enabled`: Whether this policy is currently active.
Retry is only triggered for leaf-level `Action` instances marked with `is_retryable=True`
and registered with an appropriate `RetryHandler`.
Example:
RetryPolicy(max_retries=3, delay=1.0, backoff=2.0, jitter=0.2, enabled=True)
Use `enable_policy()` to activate the policy after construction.
See Also:
- `RetryHandler`: Executes retry logic based on this configuration.
- `HookType.ON_ERROR`: The hook type used to trigger retries.
"""
max_retries: int = Field(default=3, ge=0)
delay: float = Field(default=1.0, ge=0.0)
@ -36,7 +75,27 @@ class RetryPolicy(BaseModel):
class RetryHandler:
"""RetryHandler class to manage retry policies for actions."""
"""
Executes retry logic for Falyx actions using a provided `RetryPolicy`.
This class is intended to be registered as an `on_error` hook. It will
re-attempt the failed `Action`'s `action` method using the args/kwargs from
the failed context, following exponential backoff and optional jitter.
Only supports retrying leaf `Action` instances (not ChainedAction or ActionGroup)
where `is_retryable=True`.
Attributes:
policy (RetryPolicy): The retry configuration controlling timing and limits.
Example:
handler = RetryHandler(RetryPolicy(max_retries=3, delay=1.0, enabled=True))
action.hooks.register(HookType.ON_ERROR, handler.retry_on_error)
Notes:
- Retries are not triggered if the policy is disabled or `max_retries=0`.
- All retry attempts and final failure are logged automatically.
"""
def __init__(self, policy: RetryPolicy = RetryPolicy()):
self.policy = policy
@ -53,7 +112,7 @@ class RetryHandler:
self.policy.delay = delay
self.policy.backoff = backoff
self.policy.jitter = jitter
logger.info("🔄 Retry policy enabled: %s", self.policy)
logger.info("Retry policy enabled: %s", self.policy)
async def retry_on_error(self, context: ExecutionContext) -> None:
from falyx.action import Action
@ -67,21 +126,21 @@ class RetryHandler:
last_error = error
if not target:
logger.warning("[%s] ⚠️ No action target. Cannot retry.", name)
logger.warning("[%s] No action target. Cannot retry.", name)
return None
if not isinstance(target, Action):
logger.warning(
"[%s] RetryHandler only supports only supports Action objects.", name
"[%s] RetryHandler only supports only supports Action objects.", name
)
return None
if not getattr(target, "is_retryable", False):
logger.warning("[%s] Not retryable.", name)
logger.warning("[%s] Not retryable.", name)
return None
if not self.policy.enabled:
logger.warning("[%s] Retry policy is disabled.", name)
logger.warning("[%s] Retry policy is disabled.", name)
return None
while retries_done < self.policy.max_retries:
@ -90,32 +149,41 @@ class RetryHandler:
sleep_delay = current_delay
if self.policy.jitter > 0:
sleep_delay += random.uniform(-self.policy.jitter, self.policy.jitter)
logger.debug(
"[%s] Error: %s",
name,
last_error,
)
logger.info(
"[%s] 🔄 Retrying (%s/%s) in %ss due to '%s'...",
"[%s] Retrying (%s/%s) in %ss due to '%s'...",
name,
retries_done,
self.policy.max_retries,
current_delay,
last_error,
last_error.__class__.__name__,
)
await asyncio.sleep(current_delay)
try:
result = await target.action(*context.args, **context.kwargs)
context.result = result
context.exception = None
logger.info("[%s] Retry succeeded on attempt %s.", name, retries_done)
logger.info("[%s] Retry succeeded on attempt %s.", name, retries_done)
return None
except Exception as retry_error:
last_error = retry_error
current_delay *= self.policy.backoff
logger.debug(
"[%s] Error: %s",
name,
retry_error,
)
logger.warning(
"[%s] ⚠️ Retry attempt %s/%s failed due to '%s'.",
"[%s] Retry attempt %s/%s failed due to '%s'.",
name,
retries_done,
self.policy.max_retries,
retry_error,
retry_error.__class__.__name__,
)
context.exception = last_error
logger.error("[%s] All %s retries failed.", name, self.policy.max_retries)
logger.error("[%s] All %s retries failed.", name, self.policy.max_retries)

View File

@ -1,6 +1,16 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""retry_utils.py"""
from falyx.action.action import Action, BaseAction
"""
Utilities for enabling retry behavior across Falyx actions.
This module provides a helper to recursively apply a `RetryPolicy` to an action and its
nested children (e.g. `ChainedAction`, `ActionGroup`), and register the appropriate
`RetryHandler` to hook into error handling.
Includes:
- `enable_retries_recursively`: Attaches a retry policy and error hook to all eligible actions.
"""
from falyx.action.action import Action
from falyx.action.base_action import BaseAction
from falyx.hook_manager import HookType
from falyx.retry import RetryHandler, RetryPolicy

View File

@ -1,17 +1,30 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""selection.py"""
"""
Provides interactive selection utilities for Falyx CLI actions.
This module defines `SelectionOption` objects, selection maps, and rich-powered
rendering functions to build interactive selection prompts using `prompt_toolkit`.
It supports:
- Grid-based and dictionary-based selection menus
- Index- or key-driven multi-select prompts
- Formatted Rich tables for CLI visual menus
- Cancel keys, defaults, and duplication control
Used by `SelectionAction` and other prompt-driven workflows within Falyx.
"""
from dataclasses import dataclass
from typing import Any, Callable, KeysView, Sequence
from prompt_toolkit import PromptSession
from rich import box
from rich.console import Console
from rich.markup import escape
from rich.table import Table
from falyx.console import console
from falyx.prompt_utils import rich_text_to_prompt_text
from falyx.themes import OneColors
from falyx.utils import chunks
from falyx.validators import int_range_validator, key_validator
from falyx.utils import CaseInsensitiveDict, chunks
from falyx.validators import MultiIndexValidator, MultiKeyValidator
@dataclass
@ -32,6 +45,62 @@ class SelectionOption:
return f"[{OneColors.WHITE}]{key}[/] [{self.style}]{self.description}[/]"
class SelectionOptionMap(CaseInsensitiveDict):
"""
Manages selection options including validation and reserved key protection.
"""
RESERVED_KEYS: set[str] = set()
def __init__(
self,
options: dict[str, SelectionOption] | None = None,
allow_reserved: bool = False,
):
super().__init__()
self.allow_reserved = allow_reserved
if options:
self.update(options)
def _add_reserved(self, key: str, option: SelectionOption) -> None:
"""Add a reserved key, bypassing validation."""
norm_key = key.upper()
super().__setitem__(norm_key, option)
def __setitem__(self, key: str, option: SelectionOption) -> None:
if not isinstance(option, SelectionOption):
raise TypeError(f"Value for key '{key}' must be a SelectionOption.")
norm_key = key.upper()
if norm_key in self.RESERVED_KEYS and not self.allow_reserved:
raise ValueError(
f"Key '{key}' is reserved and cannot be used in SelectionOptionMap."
)
super().__setitem__(norm_key, option)
def __delitem__(self, key: str) -> None:
if key.upper() in self.RESERVED_KEYS and not self.allow_reserved:
raise ValueError(f"Cannot delete reserved option '{key}'.")
super().__delitem__(key)
def update(self, other=None, **kwargs):
"""Update the selection options with another dictionary."""
if other:
for key, option in other.items():
if not isinstance(option, SelectionOption):
raise TypeError(f"Value for key '{key}' must be a SelectionOption.")
self[key] = option
for key, option in kwargs.items():
if not isinstance(option, SelectionOption):
raise TypeError(f"Value for key '{key}' must be a SelectionOption.")
self[key] = option
def items(self, include_reserved: bool = True):
for k, v in super().items():
if not include_reserved and k in self.RESERVED_KEYS:
continue
yield k, v
def render_table_base(
title: str,
*,
@ -211,23 +280,37 @@ async def prompt_for_index(
*,
min_index: int = 0,
default_selection: str = "",
console: Console | None = None,
prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ",
show_table: bool = True,
):
number_selections: int | str = 1,
separator: str = ",",
allow_duplicates: bool = False,
cancel_key: str = "",
) -> int | list[int]:
prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="auto")
if show_table:
console.print(table, justify="center")
selection = await prompt_session.prompt_async(
message=prompt_message,
validator=int_range_validator(min_index, max_index),
message=rich_text_to_prompt_text(prompt_message),
validator=MultiIndexValidator(
min_index,
max_index,
number_selections,
separator,
allow_duplicates,
cancel_key,
),
default=default_selection,
)
return int(selection)
if selection.strip() == cancel_key:
return int(cancel_key)
if isinstance(number_selections, int) and number_selections == 1:
return int(selection.strip())
return [int(index.strip()) for index in selection.strip().split(separator)]
async def prompt_for_selection(
@ -235,35 +318,46 @@ async def prompt_for_selection(
table: Table,
*,
default_selection: str = "",
console: Console | None = None,
prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ",
show_table: bool = True,
) -> str:
number_selections: int | str = 1,
separator: str = ",",
allow_duplicates: bool = False,
cancel_key: str = "",
) -> str | list[str]:
"""Prompt the user to select a key from a set of options. Return the selected key."""
prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="auto")
if show_table:
console.print(table, justify="center")
selected = await prompt_session.prompt_async(
message=prompt_message,
validator=key_validator(keys),
message=rich_text_to_prompt_text(prompt_message),
validator=MultiKeyValidator(
keys, number_selections, separator, allow_duplicates, cancel_key
),
default=default_selection,
)
return selected
if selected.strip() == cancel_key:
return cancel_key
if isinstance(number_selections, int) and number_selections == 1:
return selected.strip()
return [key.strip() for key in selected.strip().split(separator)]
async def select_value_from_list(
title: str,
selections: Sequence[str],
*,
console: Console | None = None,
prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ",
default_selection: str = "",
number_selections: int | str = 1,
separator: str = ",",
allow_duplicates: bool = False,
cancel_key: str = "",
columns: int = 4,
caption: str = "",
box_style: box.Box = box.SIMPLE,
@ -276,7 +370,7 @@ async def select_value_from_list(
title_style: str = "",
caption_style: str = "",
highlight: bool = False,
):
) -> str | list[str]:
"""Prompt for a selection. Return the selected item."""
table = render_selection_indexed_table(
title=title,
@ -295,17 +389,21 @@ async def select_value_from_list(
highlight=highlight,
)
prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="auto")
selection_index = await prompt_for_index(
len(selections) - 1,
table,
default_selection=default_selection,
console=console,
prompt_session=prompt_session,
prompt_message=prompt_message,
number_selections=number_selections,
separator=separator,
allow_duplicates=allow_duplicates,
cancel_key=cancel_key,
)
if isinstance(selection_index, list):
return [selections[i] for i in selection_index]
return selections[selection_index]
@ -313,14 +411,16 @@ async def select_key_from_dict(
selections: dict[str, SelectionOption],
table: Table,
*,
console: Console | None = None,
prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ",
default_selection: str = "",
) -> Any:
number_selections: int | str = 1,
separator: str = ",",
allow_duplicates: bool = False,
cancel_key: str = "",
) -> str | list[str]:
"""Prompt for a key from a dict, returns the key."""
prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="auto")
console.print(table, justify="center")
@ -328,9 +428,12 @@ async def select_key_from_dict(
selections.keys(),
table,
default_selection=default_selection,
console=console,
prompt_session=prompt_session,
prompt_message=prompt_message,
number_selections=number_selections,
separator=separator,
allow_duplicates=allow_duplicates,
cancel_key=cancel_key,
)
@ -338,14 +441,16 @@ async def select_value_from_dict(
selections: dict[str, SelectionOption],
table: Table,
*,
console: Console | None = None,
prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ",
default_selection: str = "",
) -> Any:
number_selections: int | str = 1,
separator: str = ",",
allow_duplicates: bool = False,
cancel_key: str = "",
) -> Any | list[Any]:
"""Prompt for a key from a dict, but return the value."""
prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="auto")
console.print(table, justify="center")
@ -353,11 +458,16 @@ async def select_value_from_dict(
selections.keys(),
table,
default_selection=default_selection,
console=console,
prompt_session=prompt_session,
prompt_message=prompt_message,
number_selections=number_selections,
separator=separator,
allow_duplicates=allow_duplicates,
cancel_key=cancel_key,
)
if isinstance(selection_key, list):
return [selections[key].value for key in selection_key]
return selections[selection_key].value
@ -365,11 +475,14 @@ async def get_selection_from_dict_menu(
title: str,
selections: dict[str, SelectionOption],
*,
console: Console | None = None,
prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ",
default_selection: str = "",
):
number_selections: int | str = 1,
separator: str = ",",
allow_duplicates: bool = False,
cancel_key: str = "",
) -> Any | list[Any]:
"""Prompt for a key from a dict, but return the value."""
table = render_selection_dict_table(
title,
@ -379,8 +492,11 @@ async def get_selection_from_dict_menu(
return await select_value_from_dict(
selections=selections,
table=table,
console=console,
prompt_session=prompt_session,
prompt_message=prompt_message,
default_selection=default_selection,
number_selections=number_selections,
separator=separator,
allow_duplicates=allow_duplicates,
cancel_key=cancel_key,
)

View File

@ -1,5 +1,21 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""signals.py"""
"""
Defines flow control signals used internally by the Falyx CLI framework.
These signals are raised to interrupt or redirect CLI execution flow
(e.g., returning to a menu, quitting, or displaying help) without
being treated as traditional exceptions.
All signals inherit from `FlowSignal`, which is a subclass of `BaseException`
to ensure they bypass standard `except Exception` blocks.
Signals:
- BreakChainSignal: Exit a chained action early.
- QuitSignal: Terminate the CLI session.
- BackSignal: Return to the previous menu or caller.
- CancelSignal: Cancel the current operation.
- HelpSignal: Trigger help output in interactive flows.
"""
class FlowSignal(BaseException):
@ -10,6 +26,13 @@ class FlowSignal(BaseException):
"""
class BreakChainSignal(FlowSignal):
"""Raised to break the current action chain and return to the previous context."""
def __init__(self, message: str = "Break chain signal received."):
super().__init__(message)
class QuitSignal(FlowSignal):
"""Raised to signal an immediate exit from the CLI framework."""

248
falyx/spinner_manager.py Normal file
View File

@ -0,0 +1,248 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Centralized spinner rendering for Falyx CLI.
This module provides the `SpinnerManager` class, which manages a collection of
Rich spinners that can be displayed concurrently during long-running tasks.
Key Features:
• Automatic lifecycle management:
- Starts a single Rich `Live` loop when the first spinner is added.
- Stops and clears the display when the last spinner is removed.
• Thread/async-safe start logic via a lightweight lock to prevent
duplicate Live loops from being launched.
• Supports multiple spinners running simultaneously, each with its own
text, style, type, and speed.
• Integrates with Falyx's OptionsManager so actions and commands can
declaratively request spinners without directly managing terminal state.
Classes:
SpinnerData:
Lightweight container for individual spinner settings (message,
type, style, speed) and its underlying Rich `Spinner` object.
SpinnerManager:
Manages all active spinners, handles Live rendering, and provides
methods to add, update, and remove spinners.
Example:
```python
>>> manager = SpinnerManager()
>>> await manager.add("build", "Building project…", spinner_type="dots")
>>> await manager.add("deploy", "Deploying to AWS…", spinner_type="earth")
# Both spinners animate in one unified Live panel
>>> manager.remove("build")
>>> manager.remove("deploy")
```
Design Notes:
• SpinnerManager should only create **one** Live loop at a time.
• When no spinners remain, the Live panel is cleared (`transient=True`)
so the CLI output returns to a clean state.
• Hooks in `falyx.hooks` (spinner_before_hook / spinner_teardown_hook)
call into this manager automatically when `spinner=True` is set on
an Action or Command.
"""
import asyncio
from rich.console import Group
from rich.live import Live
from rich.spinner import Spinner
from falyx.console import console
from falyx.logger import logger
from falyx.themes import OneColors
class SpinnerData:
"""
Holds the configuration and Rich spinner object for a single task.
This class is a lightweight container for spinner metadata, storing the
message text, spinner type, style, and speed. It also initializes the
corresponding Rich `Spinner` instance used by `SpinnerManager` for
rendering.
Attributes:
text (str): The message displayed next to the spinner.
spinner_type (str): The Rich spinner preset to use (e.g., "dots",
"bouncingBall", "earth").
spinner_style (str): Rich color/style for the spinner animation.
spinner (Spinner): The instantiated Rich spinner object.
Example:
```
>>> data = SpinnerData("Deploying...", spinner_type="earth",
... spinner_style="cyan", spinner_speed=1.0)
>>> data.spinner
<rich.spinner.Spinner object ...>
```
"""
def __init__(
self, text: str, spinner_type: str, spinner_style: str, spinner_speed: float
):
"""Initialize a spinner with text, type, style, and speed."""
self.text = text
self.spinner_type = spinner_type
self.spinner_style = spinner_style
self.spinner = Spinner(
spinner_type, text=text, style=spinner_style, speed=spinner_speed
)
class SpinnerManager:
"""
Manages multiple Rich spinners and handles their terminal rendering.
SpinnerManager maintains a registry of active spinners and a single
Rich `Live` display loop to render them. When the first spinner is added,
the Live loop starts automatically. When the last spinner is removed,
the Live loop stops and the panel clears (via `transient=True`).
This class is designed for integration with Falyx's `OptionsManager`
so any Action or Command can declaratively register spinners without
directly controlling terminal state.
Key Behaviors:
• Starts exactly one `Live` loop, protected by a start lock to prevent
duplicate launches in async/threaded contexts.
• Supports multiple simultaneous spinners, each with independent
text, style, and type.
• Clears the display when all spinners are removed.
Attributes:
console (Console): The Rich console used for rendering.
_spinners (dict[str, SpinnerData]): Internal store of active spinners.
_task (asyncio.Task | None): The running Live loop task, if any.
_running (bool): Indicates if the Live loop is currently active.
Example:
```
>>> manager = SpinnerManager()
>>> await manager.add("build", "Building project…")
>>> await manager.add("deploy", "Deploying services…", spinner_type="earth")
>>> manager.remove("build")
>>> manager.remove("deploy")
```
"""
def __init__(self) -> None:
"""Initialize the SpinnerManager with an empty spinner registry."""
self.console = console
self._spinners: dict[str, SpinnerData] = {}
self._task: asyncio.Task | None = None
self._running: bool = False
self._lock = asyncio.Lock()
async def add(
self,
name: str,
text: str,
spinner_type: str = "dots",
spinner_style: str = OneColors.CYAN,
spinner_speed: float = 1.0,
):
"""Add a new spinner and start the Live loop if not already running."""
self._spinners[name] = SpinnerData(
text=text,
spinner_type=spinner_type,
spinner_style=spinner_style,
spinner_speed=spinner_speed,
)
async with self._lock:
if not self._running:
logger.debug("[%s] Starting spinner manager Live loop.", name)
await self._start_live()
def update(
self,
name: str,
text: str | None = None,
spinner_type: str | None = None,
spinner_style: str | None = None,
):
"""Update an existing spinner's message, style, or type."""
if name in self._spinners:
data = self._spinners[name]
if text:
data.text = text
data.spinner.text = text
if spinner_style:
data.spinner_style = spinner_style
data.spinner.style = spinner_style
if spinner_type:
data.spinner_type = spinner_type
data.spinner = Spinner(spinner_type, text=data.text)
async def remove(self, name: str):
"""Remove a spinner and stop the Live loop if no spinners remain."""
self._spinners.pop(name, None)
async with self._lock:
if not self._spinners:
logger.debug("[%s] Stopping spinner manager, no spinners left.", name)
if self._task:
self._task.cancel()
self._running = False
async def _start_live(self):
"""Start the Live rendering loop in the background."""
self._running = True
self._task = asyncio.create_task(self._live_loop())
def render_panel(self):
"""Render all active spinners as a grouped Rich panel."""
rows = []
for data in self._spinners.values():
rows.append(data.spinner)
return Group(*rows)
async def _live_loop(self):
"""Continuously refresh the spinner display until stopped."""
with Live(
self.render_panel(),
refresh_per_second=12.5,
console=self.console,
transient=True,
) as live:
while self._spinners:
live.update(self.render_panel())
await asyncio.sleep(0.1)
if __name__ == "__main__":
spinner_manager = SpinnerManager()
async def demo():
# Add multiple spinners
await spinner_manager.add("task1", "Loading configs…")
await spinner_manager.add(
"task2", "Building containers…", spinner_type="bouncingBall"
)
await spinner_manager.add("task3", "Deploying services…", spinner_type="earth")
# Simulate work
await asyncio.sleep(2)
spinner_manager.update("task1", text="Configs loaded ✅")
await asyncio.sleep(1)
spinner_manager.remove("task1")
await spinner_manager.add("task4", "Running Tests...")
await asyncio.sleep(2)
spinner_manager.update("task2", text="Build complete ✅")
spinner_manager.remove("task2")
await asyncio.sleep(1)
spinner_manager.update("task3", text="Deployed! 🎉")
await asyncio.sleep(1)
spinner_manager.remove("task3")
await asyncio.sleep(5)
spinner_manager.update("task4", "Tests Complete!")
spinner_manager.remove("task4")
console.print("Done!")
asyncio.run(demo())

View File

@ -1,5 +1,15 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""tagged_table.py"""
"""
Generates a Rich table view of Falyx commands grouped by their tags.
This module defines a utility function for rendering a custom CLI command
table that organizes commands into groups based on their first tag. It is
used to visually separate commands in interactive menus for better clarity
and discoverability.
Functions:
- build_tagged_table(flx): Returns a `rich.Table` of commands grouped by tag.
"""
from collections import defaultdict
from rich import box

View File

@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
colors.py
A Python module that integrates the Nord color palette with the Rich library.
It defines a metaclass-based NordColors class allowing dynamic attribute lookups
(e.g., NORD12bu -> "#D08770 bold underline") and provides a comprehensive Nord-based

View File

@ -1,5 +1,21 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""utils.py"""
"""
General-purpose utilities and helpers for the Falyx CLI framework.
This module includes asynchronous wrappers, logging setup, formatting utilities,
and small type-safe enhancements such as `CaseInsensitiveDict` and coroutine enforcement.
Features:
- `ensure_async`: Wraps sync functions as async coroutines.
- `chunks`: Splits an iterable into fixed-size chunks.
- `CaseInsensitiveDict`: Dict subclass with case-insensitive string keys.
- `setup_logging`: Configures Rich or JSON logging based on environment or container detection.
- `get_program_invocation`: Returns the recommended CLI command to invoke the program.
- `running_in_container`: Detects if the process is running inside a container.
These utilities support consistent behavior across CLI rendering, logging,
command parsing, and compatibility layers.
"""
from __future__ import annotations
import functools
@ -14,6 +30,8 @@ from typing import Any, Awaitable, Callable, TypeVar
import pythonjsonlogger.json
from rich.logging import RichHandler
from falyx.console import console
T = TypeVar("T")
@ -164,6 +182,7 @@ def setup_logging(
if mode == "cli":
console_handler: RichHandler | logging.StreamHandler = RichHandler(
console=console,
rich_tracebacks=True,
show_time=True,
show_level=True,
@ -184,7 +203,7 @@ def setup_logging(
console_handler.setLevel(console_log_level)
root.addHandler(console_handler)
file_handler = logging.FileHandler(log_filename)
file_handler = logging.FileHandler(log_filename, "a", "UTF-8")
file_handler.setLevel(file_log_level)
if json_log_to_file:
file_handler.setFormatter(

View File

@ -1,8 +1,61 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""validators.py"""
from typing import KeysView, Sequence
"""
Input validators for use with Prompt Toolkit and interactive Falyx CLI workflows.
from prompt_toolkit.validation import Validator
This module defines reusable `Validator` instances and subclasses that enforce valid
user input during prompts—especially for selection actions, confirmations, and
argument parsing.
Included Validators:
- CommandValidator: Validates if the input matches a known command.
- int_range_validator: Enforces numeric input within a range.
- key_validator: Ensures the entered value matches a valid selection key.
- yes_no_validator: Restricts input to 'Y' or 'N'.
- word_validator / words_validator: Accepts specific valid words (case-insensitive).
- MultiIndexValidator: Validates numeric list input (e.g. "1,2,3").
- MultiKeyValidator: Validates string key list input (e.g. "A,B,C").
These validators integrate directly into `PromptSession.prompt_async()` to
enforce correctness and provide helpful error messages.
"""
from typing import TYPE_CHECKING, KeysView, Sequence
from prompt_toolkit.validation import ValidationError, Validator
if TYPE_CHECKING:
from falyx.falyx import Falyx
class CommandValidator(Validator):
"""Validator to check if the input is a valid command."""
def __init__(self, falyx: "Falyx", error_message: str) -> None:
super().__init__()
self.falyx = falyx
self.error_message = error_message
def validate(self, document) -> None:
if not document.text:
raise ValidationError(
message=self.error_message,
cursor_position=len(document.text),
)
async def validate_async(self, document) -> None:
text = document.text
if not text:
raise ValidationError(
message=self.error_message,
cursor_position=len(text),
)
is_preview, choice, _, __ = await self.falyx.get_command(text, from_validate=True)
if is_preview:
return None
if not choice:
raise ValidationError(
message=self.error_message,
cursor_position=len(text),
)
def int_range_validator(minimum: int, maximum: int) -> Validator:
@ -44,4 +97,119 @@ def yes_no_validator() -> Validator:
return False
return True
return Validator.from_callable(validate, error_message="Enter 'Y' or 'n'.")
return Validator.from_callable(validate, error_message="Enter 'Y', 'y' or 'N', 'n'.")
def words_validator(
keys: Sequence[str] | KeysView[str], error_message: str | None = None
) -> Validator:
"""Validator for specific word inputs."""
def validate(text: str) -> bool:
if text.upper() not in [key.upper() for key in keys]:
return False
return True
if error_message is None:
error_message = f"Invalid input. Choices: {{{', '.join(keys)}}}."
return Validator.from_callable(validate, error_message=error_message)
def word_validator(word: str) -> Validator:
"""Validator for specific word inputs."""
def validate(text: str) -> bool:
if text.upper().strip() == "N":
return True
return text.upper().strip() == word.upper()
return Validator.from_callable(validate, error_message=f"Enter '{word}' or 'N', 'n'.")
class MultiIndexValidator(Validator):
def __init__(
self,
minimum: int,
maximum: int,
number_selections: int | str,
separator: str,
allow_duplicates: bool,
cancel_key: str,
) -> None:
self.minimum = minimum
self.maximum = maximum
self.number_selections = number_selections
self.separator = separator
self.allow_duplicates = allow_duplicates
self.cancel_key = cancel_key
super().__init__()
def validate(self, document):
selections = [
index.strip() for index in document.text.strip().split(self.separator)
]
if not selections or selections == [""]:
raise ValidationError(message="Select at least 1 item.")
if self.cancel_key in selections and len(selections) == 1:
return
elif self.cancel_key in selections:
raise ValidationError(message="Cancel key must be selected alone.")
for selection in selections:
try:
index = int(selection)
if not self.minimum <= index <= self.maximum:
raise ValidationError(
message=f"Invalid selection: {selection}. Select a number between {self.minimum} and {self.maximum}."
)
except ValueError:
raise ValidationError(
message=f"Invalid selection: {selection}. Select a number between {self.minimum} and {self.maximum}."
)
if not self.allow_duplicates and selections.count(selection) > 1:
raise ValidationError(message=f"Duplicate selection: {selection}")
if isinstance(self.number_selections, int):
if self.number_selections == 1 and len(selections) > 1:
raise ValidationError(message="Invalid selection. Select only 1 item.")
if len(selections) != self.number_selections:
raise ValidationError(
message=f"Select exactly {self.number_selections} items separated by '{self.separator}'"
)
class MultiKeyValidator(Validator):
def __init__(
self,
keys: Sequence[str] | KeysView[str],
number_selections: int | str,
separator: str,
allow_duplicates: bool,
cancel_key: str,
) -> None:
self.keys = keys
self.separator = separator
self.number_selections = number_selections
self.allow_duplicates = allow_duplicates
self.cancel_key = cancel_key
super().__init__()
def validate(self, document):
selections = [key.strip() for key in document.text.strip().split(self.separator)]
if not selections or selections == [""]:
raise ValidationError(message="Select at least 1 item.")
if self.cancel_key in selections and len(selections) == 1:
return
elif self.cancel_key in selections:
raise ValidationError(message="Cancel key must be selected alone.")
for selection in selections:
if selection.upper() not in [key.upper() for key in self.keys]:
raise ValidationError(message=f"Invalid selection: {selection}")
if not self.allow_duplicates and selections.count(selection) > 1:
raise ValidationError(message=f"Duplicate selection: {selection}")
if isinstance(self.number_selections, int):
if self.number_selections == 1 and len(selections) > 1:
raise ValidationError(message="Invalid selection. Select only 1 item.")
if len(selections) != self.number_selections:
raise ValidationError(
message=f"Select exactly {self.number_selections} items separated by '{self.separator}'"
)

View File

@ -1 +1 @@
__version__ = "0.1.29"
__version__ = "0.1.78"

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "falyx"
version = "0.1.29"
version = "0.1.78"
description = "Reliable and introspectable async CLI action framework."
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
license = "MIT"
@ -10,12 +10,13 @@ packages = [{ include = "falyx" }]
[tool.poetry.dependencies]
python = ">=3.10"
prompt_toolkit = "^3.0"
rich = "^13.0"
rich = "^14.0"
pydantic = "^2.0"
python-json-logger = "^3.3.0"
toml = "^0.10"
pyyaml = "^6.0"
aiohttp = "^3.11"
python-dateutil = "^2.8"
[tool.poetry.group.dev.dependencies]
pytest = "^8.3.5"
@ -26,6 +27,10 @@ black = { version = "^25.0", allow-prereleases = true }
mypy = { version = "^1.0", allow-prereleases = true }
isort = { version = "^5.0", allow-prereleases = true }
pytest-cov = "^4.0"
mkdocs = "^1.6.1"
mkdocs-material = "^9.6.14"
mkdocstrings = {extras = ["python"], version = "^0.29.1"}
mike = "^2.1.3"
[tool.poetry.scripts]
falyx = "falyx.__main__:main"

View File

@ -1,6 +1,12 @@
import pytest
from falyx.action import Action, ChainedAction, FallbackAction, LiteralInputAction
from falyx.action import (
Action,
ActionGroup,
ChainedAction,
FallbackAction,
LiteralInputAction,
)
from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er
@ -40,11 +46,11 @@ async def test_action_async_callable():
assert result == "Hello, World!"
assert (
str(action)
== "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False)"
== "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False, rollback=False)"
)
assert (
repr(action)
== "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False)"
== "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False, rollback=False)"
)
@ -59,11 +65,12 @@ async def test_chained_action():
return_list=True,
)
print(chain)
result = await chain()
assert result == [1, 2]
assert (
str(chain)
== "ChainedAction(name='Simple Chain', actions=['one', 'two'], auto_inject=False, return_list=True)"
== "ChainedAction(name=Simple Chain, actions=['one', 'two'], args=(), kwargs={}, auto_inject=False, return_list=True)"
)
@ -72,17 +79,17 @@ async def test_action_group():
"""Test if ActionGroup can be created and used."""
action1 = Action("one", lambda: 1)
action2 = Action("two", lambda: 2)
group = ChainedAction(
group = ActionGroup(
name="Simple Group",
actions=[action1, action2],
return_list=True,
)
print(group)
result = await group()
assert result == [1, 2]
assert result == [("one", 1), ("two", 2)]
assert (
str(group)
== "ChainedAction(name='Simple Group', actions=['one', 'two'], auto_inject=False, return_list=True)"
== "ActionGroup(name=Simple Group, actions=['one', 'two'], args=(), kwargs={}, inject_last_result=False, inject_into=last_result)"
)

View File

@ -0,0 +1,23 @@
import pytest
from falyx.action import Action, ActionFactory, ChainedAction
def make_chain(value) -> ChainedAction:
return ChainedAction(
"test_chain",
[
Action("action1", lambda: value + "_1"),
Action("action2", lambda: value + "_2"),
],
return_list=True,
)
@pytest.mark.asyncio
async def test_action_factory_action():
action = ActionFactory(name="test_action", factory=make_chain, args=("test_value",))
result = await action()
assert result == ["test_value_1", "test_value_2"]

View File

@ -0,0 +1,145 @@
import pytest
from falyx.action.action_types import ConfirmType, FileType, SelectionReturnType
def test_file_type_enum():
"""Test if the FileType enum has all expected members."""
assert FileType.TEXT.value == "text"
assert FileType.PATH.value == "path"
assert FileType.JSON.value == "json"
assert FileType.TOML.value == "toml"
assert FileType.YAML.value == "yaml"
assert FileType.CSV.value == "csv"
assert FileType.TSV.value == "tsv"
assert FileType.XML.value == "xml"
assert str(FileType.TEXT) == "text"
def test_file_type_choices():
"""Test if the FileType choices method returns all enum members."""
choices = FileType.choices()
assert len(choices) == 8
assert all(isinstance(choice, FileType) for choice in choices)
def test_file_type_missing():
"""Test if the _missing_ method raises ValueError for invalid values."""
with pytest.raises(ValueError, match="Invalid FileType: 'invalid'"):
FileType._missing_("invalid")
with pytest.raises(ValueError, match="Invalid FileType: 123"):
FileType._missing_(123)
def test_file_type_aliases():
"""Test if the _get_alias method returns correct aliases."""
assert FileType._get_alias("file") == "path"
assert FileType._get_alias("filepath") == "path"
assert FileType._get_alias("unknown") == "unknown"
def test_file_type_missing_aliases():
"""Test if the _missing_ method handles aliases correctly."""
assert FileType._missing_("file") == FileType.PATH
assert FileType._missing_("filepath") == FileType.PATH
with pytest.raises(ValueError, match="Invalid FileType: 'unknown'"):
FileType._missing_("unknown")
def test_confirm_type_enum():
"""Test if the ConfirmType enum has all expected members."""
assert ConfirmType.YES_NO.value == "yes_no"
assert ConfirmType.YES_CANCEL.value == "yes_cancel"
assert ConfirmType.YES_NO_CANCEL.value == "yes_no_cancel"
assert ConfirmType.TYPE_WORD.value == "type_word"
assert ConfirmType.TYPE_WORD_CANCEL.value == "type_word_cancel"
assert ConfirmType.OK_CANCEL.value == "ok_cancel"
assert ConfirmType.ACKNOWLEDGE.value == "acknowledge"
assert str(ConfirmType.YES_NO) == "yes_no"
def test_confirm_type_choices():
"""Test if the ConfirmType choices method returns all enum members."""
choices = ConfirmType.choices()
assert len(choices) == 7
assert all(isinstance(choice, ConfirmType) for choice in choices)
def test_confirm_type_missing():
"""Test if the _missing_ method raises ValueError for invalid values."""
with pytest.raises(ValueError, match="Invalid ConfirmType: 'invalid'"):
ConfirmType._missing_("invalid")
with pytest.raises(ValueError, match="Invalid ConfirmType: 123"):
ConfirmType._missing_(123)
def test_confirm_type_aliases():
"""Test if the _get_alias method returns correct aliases."""
assert ConfirmType._get_alias("yes") == "yes_no"
assert ConfirmType._get_alias("ok") == "ok_cancel"
assert ConfirmType._get_alias("type") == "type_word"
assert ConfirmType._get_alias("word") == "type_word"
assert ConfirmType._get_alias("word_cancel") == "type_word_cancel"
assert ConfirmType._get_alias("ack") == "acknowledge"
def test_confirm_type_missing_aliases():
"""Test if the _missing_ method handles aliases correctly."""
assert ConfirmType("yes") == ConfirmType.YES_NO
assert ConfirmType("ok") == ConfirmType.OK_CANCEL
assert ConfirmType("word") == ConfirmType.TYPE_WORD
assert ConfirmType("ack") == ConfirmType.ACKNOWLEDGE
with pytest.raises(ValueError, match="Invalid ConfirmType: 'unknown'"):
ConfirmType._missing_("unknown")
def test_selection_return_type_enum():
"""Test if the SelectionReturnType enum has all expected members."""
assert SelectionReturnType.KEY.value == "key"
assert SelectionReturnType.VALUE.value == "value"
assert SelectionReturnType.DESCRIPTION.value == "description"
assert SelectionReturnType.DESCRIPTION_VALUE.value == "description_value"
assert SelectionReturnType.ITEMS.value == "items"
assert str(SelectionReturnType.KEY) == "key"
def test_selection_return_type_choices():
"""Test if the SelectionReturnType choices method returns all enum members."""
choices = SelectionReturnType.choices()
assert len(choices) == 5
assert all(isinstance(choice, SelectionReturnType) for choice in choices)
def test_selection_return_type_missing():
"""Test if the _missing_ method raises ValueError for invalid values."""
with pytest.raises(ValueError, match="Invalid SelectionReturnType: 'invalid'"):
SelectionReturnType._missing_("invalid")
with pytest.raises(ValueError, match="Invalid SelectionReturnType: 123"):
SelectionReturnType._missing_(123)
def test_selection_return_type_aliases():
"""Test if the _get_alias method returns correct aliases."""
assert SelectionReturnType._get_alias("desc") == "description"
assert SelectionReturnType._get_alias("desc_value") == "description_value"
assert SelectionReturnType._get_alias("unknown") == "unknown"
def test_selection_return_type_missing_aliases():
"""Test if the _missing_ method handles aliases correctly."""
assert SelectionReturnType._missing_("desc") == SelectionReturnType.DESCRIPTION
assert (
SelectionReturnType._missing_("desc_value")
== SelectionReturnType.DESCRIPTION_VALUE
)
with pytest.raises(ValueError, match="Invalid SelectionReturnType: 'unknown'"):
SelectionReturnType._missing_("unknown")

View File

@ -0,0 +1,94 @@
import pytest
from falyx.action import ConfirmAction
@pytest.mark.asyncio
async def test_confirm_action_yes_no():
action = ConfirmAction(
name="test",
prompt_message="Are you sure?",
never_prompt=True,
confirm_type="yes_no",
)
result = await action()
assert result is True
@pytest.mark.asyncio
async def test_confirm_action_yes_cancel():
action = ConfirmAction(
name="test",
prompt_message="Are you sure?",
never_prompt=True,
confirm_type="yes_cancel",
)
result = await action()
assert result is True
@pytest.mark.asyncio
async def test_confirm_action_yes_no_cancel():
action = ConfirmAction(
name="test",
prompt_message="Are you sure?",
never_prompt=True,
confirm_type="yes_no_cancel",
)
result = await action()
assert result is True
@pytest.mark.asyncio
async def test_confirm_action_type_word():
action = ConfirmAction(
name="test",
prompt_message="Are you sure?",
never_prompt=True,
confirm_type="type_word",
)
result = await action()
assert result is True
@pytest.mark.asyncio
async def test_confirm_action_type_word_cancel():
action = ConfirmAction(
name="test",
prompt_message="Are you sure?",
never_prompt=True,
confirm_type="type_word_cancel",
)
result = await action()
assert result is True
@pytest.mark.asyncio
async def test_confirm_action_ok_cancel():
action = ConfirmAction(
name="test",
prompt_message="Are you sure?",
never_prompt=True,
confirm_type="ok_cancel",
)
result = await action()
assert result is True
@pytest.mark.asyncio
async def test_confirm_action_acknowledge():
action = ConfirmAction(
name="test",
prompt_message="Are you sure?",
never_prompt=True,
confirm_type="acknowledge",
)
result = await action()
assert result is True

View File

@ -0,0 +1,287 @@
import pytest
from falyx.action import SelectionAction
from falyx.selection import SelectionOption
@pytest.mark.asyncio
async def test_selection_list_never_prompt_by_value():
action = SelectionAction(
name="test",
selections=["a", "b", "c"],
default_selection="b",
never_prompt=True,
)
assert action.never_prompt is True
assert action.default_selection == "b"
result = await action()
assert result == "b"
@pytest.mark.asyncio
async def test_selection_list_never_prompt_by_index():
action = SelectionAction(
name="test",
selections=["a", "b", "c"],
default_selection="2",
never_prompt=True,
)
assert action.never_prompt is True
assert action.default_selection == "2"
result = await action()
assert result == "c"
@pytest.mark.asyncio
async def test_selection_list_never_prompt_by_value_multi_select():
action = SelectionAction(
name="test",
selections=["a", "b", "c"],
default_selection=["b", "c"],
never_prompt=True,
number_selections=2,
)
assert action.never_prompt is True
assert action.default_selection == ["b", "c"]
result = await action()
assert result == ["b", "c"]
@pytest.mark.asyncio
async def test_selection_list_never_prompt_by_index_multi_select():
action = SelectionAction(
name="test",
selections=["a", "b", "c"],
default_selection=["1", "2"],
never_prompt=True,
number_selections=2,
)
assert action.never_prompt is True
assert action.default_selection == ["1", "2"]
result = await action()
assert result == ["b", "c"]
@pytest.mark.asyncio
async def test_selection_prompt_dict_never_prompt():
action = SelectionAction(
name="test",
selections={"a": "Alpha", "b": "Beta", "c": "Gamma"},
default_selection="b",
never_prompt=True,
)
assert action.never_prompt is True
assert action.default_selection == "b"
result = await action()
assert result == "Beta"
@pytest.mark.asyncio
async def test_selection_prompt_dict_never_prompt_by_value():
action = SelectionAction(
name="test",
selections={"a": "Alpha", "b": "Beta", "c": "Gamma"},
default_selection="Beta",
never_prompt=True,
)
assert action.never_prompt is True
assert action.default_selection == "Beta"
result = await action()
assert result == "Beta"
@pytest.mark.asyncio
async def test_selection_prompt_dict_never_prompt_by_key():
action = SelectionAction(
name="test",
selections={"a": "Alpha", "b": "Beta", "c": "Gamma"},
default_selection="b",
never_prompt=True,
)
assert action.never_prompt is True
assert action.default_selection == "b"
result = await action()
assert result == "Beta"
@pytest.mark.asyncio
async def test_selection_prompt_map_never_prompt_by_key():
prompt_map = {
"a": SelectionOption(description="Alpha", value="Alpha Service"),
"b": SelectionOption(description="Beta", value="Beta Service"),
"c": SelectionOption(description="Gamma", value="Gamma Service"),
}
action = SelectionAction(
name="test",
selections=prompt_map,
default_selection="c",
never_prompt=True,
)
assert action.never_prompt is True
assert action.default_selection == "c"
result = await action()
assert result == "Gamma Service"
@pytest.mark.asyncio
async def test_selection_prompt_map_never_prompt_by_description():
prompt_map = {
"a": SelectionOption(description="Alpha", value="Alpha Service"),
"b": SelectionOption(description="Beta", value="Beta Service"),
"c": SelectionOption(description="Gamma", value="Gamma Service"),
}
action = SelectionAction(
name="test",
selections=prompt_map,
default_selection="Alpha",
never_prompt=True,
)
assert action.never_prompt is True
assert action.default_selection == "Alpha"
result = await action()
assert result == "Alpha Service"
@pytest.mark.asyncio
async def test_selection_prompt_map_never_prompt_by_value():
prompt_map = {
"a": SelectionOption(description="Alpha", value="Alpha Service"),
"b": SelectionOption(description="Beta", value="Beta Service"),
"c": SelectionOption(description="Gamma", value="Gamma Service"),
}
action = SelectionAction(
name="test",
selections=prompt_map,
default_selection="Beta Service",
never_prompt=True,
)
assert action.never_prompt is True
assert action.default_selection == "Beta Service"
result = await action()
assert result == "Beta Service"
@pytest.mark.asyncio
async def test_selection_prompt_dict_never_prompt_by_value_multi_select():
action = SelectionAction(
name="test",
selections={"a": "Alpha", "b": "Beta", "c": "Gamma"},
default_selection=["Beta", "Gamma"],
number_selections=2,
never_prompt=True,
)
assert action.never_prompt is True
assert action.default_selection == ["Beta", "Gamma"]
result = await action()
assert result == ["Beta", "Gamma"]
@pytest.mark.asyncio
async def test_selection_prompt_dict_never_prompt_by_key_multi_select():
action = SelectionAction(
name="test",
selections={"a": "Alpha", "b": "Beta", "c": "Gamma"},
default_selection=["a", "b"],
number_selections=2,
never_prompt=True,
)
assert action.never_prompt is True
assert action.default_selection == ["a", "b"]
result = await action()
assert result == ["Alpha", "Beta"]
@pytest.mark.asyncio
async def test_selection_prompt_map_never_prompt_by_key_multi_select():
prompt_map = {
"a": SelectionOption(description="Alpha", value="Alpha Service"),
"b": SelectionOption(description="Beta", value="Beta Service"),
"c": SelectionOption(description="Gamma", value="Gamma Service"),
}
action = SelectionAction(
name="test",
selections=prompt_map,
default_selection=["b", "c"],
number_selections=2,
never_prompt=True,
)
assert action.never_prompt is True
assert action.default_selection == ["b", "c"]
result = await action()
assert result == ["Beta Service", "Gamma Service"]
@pytest.mark.asyncio
async def test_selection_prompt_map_never_prompt_by_description_multi_select():
prompt_map = {
"a": SelectionOption(description="Alpha", value="Alpha Service"),
"b": SelectionOption(description="Beta", value="Beta Service"),
"c": SelectionOption(description="Gamma", value="Gamma Service"),
}
action = SelectionAction(
name="test",
selections=prompt_map,
default_selection=["Alpha", "Gamma"],
number_selections=2,
never_prompt=True,
)
assert action.never_prompt is True
assert action.default_selection == ["Alpha", "Gamma"]
result = await action()
assert result == ["Alpha Service", "Gamma Service"]
@pytest.mark.asyncio
async def test_selection_prompt_map_never_prompt_by_value_multi_select():
prompt_map = {
"a": SelectionOption(description="Alpha", value="Alpha Service"),
"b": SelectionOption(description="Beta", value="Beta Service"),
"c": SelectionOption(description="Gamma", value="Gamma Service"),
}
action = SelectionAction(
name="test",
selections=prompt_map,
default_selection=["Beta Service", "Alpha Service"],
number_selections=2,
never_prompt=True,
)
assert action.never_prompt is True
assert action.default_selection == ["Beta Service", "Alpha Service"]
result = await action()
assert result == ["Beta Service", "Alpha Service"]
@pytest.mark.asyncio
async def test_selection_prompt_map_never_prompt_by_value_wildcard():
prompt_map = {
"a": SelectionOption(description="Alpha", value="Alpha Service"),
"b": SelectionOption(description="Beta", value="Beta Service"),
"c": SelectionOption(description="Gamma", value="Gamma Service"),
}
action = SelectionAction(
name="test",
selections=prompt_map,
default_selection=["Beta Service", "Alpha Service"],
number_selections="*",
never_prompt=True,
)
assert action.never_prompt is True
assert action.default_selection == ["Beta Service", "Alpha Service"]
result = await action()
assert result == ["Beta Service", "Alpha Service"]

View File

@ -1,7 +1,7 @@
# test_command.py
import pytest
from falyx.action import Action, ActionGroup, BaseIOAction, ChainedAction
from falyx.action import Action, BaseIOAction, ChainedAction
from falyx.command import Command
from falyx.execution_registry import ExecutionRegistry as er
from falyx.retry import RetryPolicy
@ -50,108 +50,13 @@ def test_command_str():
"""Test if Command string representation is correct."""
action = Action("test_action", dummy_action)
cmd = Command(key="TEST", description="Test Command", action=action)
print(cmd)
assert (
str(cmd)
== "Command(key='TEST', description='Test Command' action='Action(name='test_action', action=dummy_action, args=(), kwargs={}, retry=False)')"
== "Command(key='TEST', description='Test Command' action='Action(name='test_action', action=dummy_action, args=(), kwargs={}, retry=False, rollback=False)')"
)
@pytest.mark.parametrize(
"action_factory, expected_requires_input",
[
(lambda: Action(name="normal", action=dummy_action), False),
(lambda: DummyInputAction(name="io"), True),
(
lambda: ChainedAction(name="chain", actions=[DummyInputAction(name="io")]),
True,
),
(
lambda: ActionGroup(name="group", actions=[DummyInputAction(name="io")]),
True,
),
],
)
def test_command_requires_input_detection(action_factory, expected_requires_input):
action = action_factory()
cmd = Command(key="TEST", description="Test Command", action=action)
assert cmd.requires_input == expected_requires_input
if expected_requires_input:
assert cmd.hidden is True
else:
assert cmd.hidden is False
def test_requires_input_flag_detected_for_baseioaction():
"""Command should automatically detect requires_input=True for BaseIOAction."""
cmd = Command(
key="X",
description="Echo input",
action=DummyInputAction(name="dummy"),
)
assert cmd.requires_input is True
assert cmd.hidden is True
def test_requires_input_manual_override():
"""Command manually set requires_input=False should not auto-hide."""
cmd = Command(
key="Y",
description="Custom input command",
action=DummyInputAction(name="dummy"),
requires_input=False,
)
assert cmd.requires_input is False
assert cmd.hidden is False
def test_default_command_does_not_require_input():
"""Normal Command without IO Action should not require input."""
cmd = Command(
key="Z",
description="Simple action",
action=lambda: 42,
)
assert cmd.requires_input is False
assert cmd.hidden is False
def test_chain_requires_input():
"""If first action in a chain requires input, the command should require input."""
chain = ChainedAction(
name="ChainWithInput",
actions=[
DummyInputAction(name="dummy"),
Action(name="action1", action=lambda: 1),
],
)
cmd = Command(
key="A",
description="Chain with input",
action=chain,
)
assert cmd.requires_input is True
assert cmd.hidden is True
def test_group_requires_input():
"""If any action in a group requires input, the command should require input."""
group = ActionGroup(
name="GroupWithInput",
actions=[
Action(name="action1", action=lambda: 1),
DummyInputAction(name="dummy"),
],
)
cmd = Command(
key="B",
description="Group with input",
action=group,
)
assert cmd.requires_input is True
assert cmd.hidden is True
def test_enable_retry():
"""Command should enable retry if action is an Action and retry is set to True."""
cmd = Command(
@ -193,13 +98,17 @@ def test_enable_retry_not_action():
cmd = Command(
key="C",
description="Retry action",
action=DummyInputAction,
action=DummyInputAction(
name="dummy_input_action",
),
retry=True,
)
assert cmd.retry is True
with pytest.raises(Exception) as exc_info:
assert cmd.action.retry_policy.enabled is False
assert "'function' object has no attribute 'retry_policy'" in str(exc_info.value)
assert "'DummyInputAction' object has no attribute 'retry_policy'" in str(
exc_info.value
)
def test_chain_retry_all():
@ -229,13 +138,17 @@ def test_chain_retry_all_not_base_action():
cmd = Command(
key="E",
description="Chain with retry",
action=DummyInputAction,
action=DummyInputAction(
name="dummy_input_action",
),
retry_all=True,
)
assert cmd.retry_all is True
with pytest.raises(Exception) as exc_info:
assert cmd.action.retry_policy.enabled is False
assert "'function' object has no attribute 'retry_policy'" in str(exc_info.value)
assert "'DummyInputAction' object has no attribute 'retry_policy'" in str(
exc_info.value
)
@pytest.mark.asyncio

View File

@ -1,102 +1,113 @@
import pytest
from falyx.exceptions import CommandArgumentError
from falyx.parsers import ArgumentAction, CommandArgumentParser
from falyx.parser import ArgumentAction, CommandArgumentParser
from falyx.signals import HelpSignal
def build_parser_and_parse(args, config):
async def build_parser_and_parse(args, config):
cap = CommandArgumentParser()
config(cap)
return cap.parse_args(args)
return await cap.parse_args(args)
def test_none():
@pytest.mark.asyncio
async def test_none():
def config(parser):
parser.add_argument("--foo", type=str)
parsed = build_parser_and_parse(None, config)
parsed = await build_parser_and_parse(None, config)
assert parsed["foo"] is None
def test_append_multiple_flags():
@pytest.mark.asyncio
async def test_append_multiple_flags():
def config(parser):
parser.add_argument("--tag", action=ArgumentAction.APPEND, type=str)
parsed = build_parser_and_parse(["--tag", "a", "--tag", "b", "--tag", "c"], config)
parsed = await build_parser_and_parse(
["--tag", "a", "--tag", "b", "--tag", "c"], config
)
assert parsed["tag"] == ["a", "b", "c"]
def test_positional_nargs_plus_and_single():
@pytest.mark.asyncio
async def test_positional_nargs_plus_and_single():
def config(parser):
parser.add_argument("files", nargs="+", type=str)
parser.add_argument("mode", nargs=1)
parsed = build_parser_and_parse(["a", "b", "c", "prod"], config)
parsed = await build_parser_and_parse(["a", "b", "c", "prod"], config)
assert parsed["files"] == ["a", "b", "c"]
assert parsed["mode"] == "prod"
def test_type_validation_failure():
@pytest.mark.asyncio
async def test_type_validation_failure():
def config(parser):
parser.add_argument("--count", type=int)
with pytest.raises(CommandArgumentError):
build_parser_and_parse(["--count", "abc"], config)
await build_parser_and_parse(["--count", "abc"], config)
def test_required_field_missing():
@pytest.mark.asyncio
async def test_required_field_missing():
def config(parser):
parser.add_argument("--env", type=str, required=True)
with pytest.raises(CommandArgumentError):
build_parser_and_parse([], config)
await build_parser_and_parse([], config)
def test_choices_enforced():
@pytest.mark.asyncio
async def test_choices_enforced():
def config(parser):
parser.add_argument("--mode", choices=["dev", "prod"])
with pytest.raises(CommandArgumentError):
build_parser_and_parse(["--mode", "staging"], config)
await build_parser_and_parse(["--mode", "staging"], config)
def test_boolean_flags():
@pytest.mark.asyncio
async def test_boolean_flags():
def config(parser):
parser.add_argument("--debug", action=ArgumentAction.STORE_TRUE)
parser.add_argument("--no-debug", action=ArgumentAction.STORE_FALSE)
parsed = build_parser_and_parse(["--debug", "--no-debug"], config)
parsed = await build_parser_and_parse(["--debug", "--no-debug"], config)
assert parsed["debug"] is True
assert parsed["no_debug"] is False
parsed = build_parser_and_parse([], config)
print(parsed)
parsed = await build_parser_and_parse([], config)
assert parsed["debug"] is False
assert parsed["no_debug"] is True
def test_count_action():
@pytest.mark.asyncio
async def test_count_action():
def config(parser):
parser.add_argument("-v", action=ArgumentAction.COUNT)
parsed = build_parser_and_parse(["-v", "-v", "-v"], config)
parsed = await build_parser_and_parse(["-v", "-v", "-v"], config)
assert parsed["v"] == 3
def test_nargs_star():
@pytest.mark.asyncio
async def test_nargs_star():
def config(parser):
parser.add_argument("args", nargs="*", type=str)
parsed = build_parser_and_parse(["one", "two", "three"], config)
parsed = await build_parser_and_parse(["one", "two", "three"], config)
assert parsed["args"] == ["one", "two", "three"]
def test_flag_and_positional_mix():
@pytest.mark.asyncio
async def test_flag_and_positional_mix():
def config(parser):
parser.add_argument("--env", type=str)
parser.add_argument("tasks", nargs="+")
parsed = build_parser_and_parse(["--env", "prod", "build", "test"], config)
parsed = await build_parser_and_parse(["--env", "prod", "build", "test"], config)
assert parsed["env"] == "prod"
assert parsed["tasks"] == ["build", "test"]
@ -134,7 +145,7 @@ def test_add_argument_multiple_optional_flags_same_dest():
parser.add_argument("-f", "--falyx")
arg = parser._arguments[-1]
assert arg.dest == "falyx"
assert arg.flags == ["-f", "--falyx"]
assert arg.flags == ("-f", "--falyx")
def test_add_argument_flag_dest_conflict():
@ -165,7 +176,7 @@ def test_add_argument_multiple_flags_custom_dest():
parser.add_argument("-f", "--falyx", "--test", dest="falyx")
arg = parser._arguments[-1]
assert arg.dest == "falyx"
assert arg.flags == ["-f", "--falyx", "--test"]
assert arg.flags == ("-f", "--falyx", "--test")
def test_add_argument_multiple_flags_dest():
@ -175,7 +186,7 @@ def test_add_argument_multiple_flags_dest():
parser.add_argument("-f", "--falyx", "--test")
arg = parser._arguments[-1]
assert arg.dest == "falyx"
assert arg.flags == ["-f", "--falyx", "--test"]
assert arg.flags == ("-f", "--falyx", "--test")
def test_add_argument_single_flag_dest():
@ -185,7 +196,7 @@ def test_add_argument_single_flag_dest():
parser.add_argument("-f")
arg = parser._arguments[-1]
assert arg.dest == "f"
assert arg.flags == ["-f"]
assert arg.flags == ("-f",)
def test_add_argument_bad_dest():
@ -257,7 +268,7 @@ def test_add_argument_default_value():
parser.add_argument("--falyx", default="default_value")
arg = parser._arguments[-1]
assert arg.dest == "falyx"
assert arg.flags == ["--falyx"]
assert arg.flags == ("--falyx",)
assert arg.default == "default_value"
@ -297,20 +308,21 @@ def test_add_argument_default_not_in_choices():
parser.add_argument("--falyx", choices=["a", "b"], default="c")
def test_add_argument_choices():
@pytest.mark.asyncio
async def test_add_argument_choices():
parser = CommandArgumentParser()
# ✅ Choices provided
parser.add_argument("--falyx", choices=["a", "b", "c"])
arg = parser._arguments[-1]
assert arg.dest == "falyx"
assert arg.flags == ["--falyx"]
assert arg.flags == ("--falyx",)
assert arg.choices == ["a", "b", "c"]
args = parser.parse_args(["--falyx", "a"])
args = await parser.parse_args(["--falyx", "a"])
assert args["falyx"] == "a"
with pytest.raises(CommandArgumentError):
parser.parse_args(["--falyx", "d"])
await parser.parse_args(["--falyx", "d"])
def test_add_argument_choices_invalid():
@ -333,26 +345,28 @@ def test_add_argument_choices_invalid():
def test_add_argument_bad_nargs():
parser = CommandArgumentParser()
# ❌ Invalid nargs value
with pytest.raises(CommandArgumentError):
parser.add_argument("--falyx", nargs="invalid")
# ❌ Invalid nargs type
with pytest.raises(CommandArgumentError):
parser.add_argument("--falyx", nargs=123)
parser.add_argument("--foo", nargs="123")
# ❌ Invalid nargs type
with pytest.raises(CommandArgumentError):
parser.add_argument("--falyx", nargs=None)
parser.add_argument("--foo", nargs=[1, 2])
with pytest.raises(CommandArgumentError):
parser.add_argument("--too", action="count", nargs=5)
with pytest.raises(CommandArgumentError):
parser.add_argument("falyx", action="store_true", nargs=5)
def test_add_argument_nargs():
parser = CommandArgumentParser()
# ✅ Valid nargs value
parser.add_argument("--falyx", nargs=2)
arg = parser._arguments[-1]
assert arg.dest == "falyx"
assert arg.flags == ["--falyx"]
assert arg.flags == ("--falyx",)
assert arg.nargs == 2
@ -377,56 +391,63 @@ def test_get_argument():
parser.add_argument("--falyx", type=str, default="default_value")
arg = parser.get_argument("falyx")
assert arg.dest == "falyx"
assert arg.flags == ["--falyx"]
assert arg.flags == ("--falyx",)
assert arg.default == "default_value"
def test_parse_args_nargs():
@pytest.mark.asyncio
async def test_parse_args_nargs():
parser = CommandArgumentParser()
parser.add_argument("files", nargs="+", type=str)
parser.add_argument("mode", nargs=1)
parser.add_argument("--action", action="store_true")
args = parser.parse_args(["a", "b", "c"])
args = await parser.parse_args(["a", "b", "c", "--action"])
assert args["files"] == ["a", "b"]
assert args["mode"] == "c"
args = await parser.parse_args(["--action", "a", "b", "c"])
assert args["files"] == ["a", "b"]
assert args["mode"] == "c"
def test_parse_args_nargs_plus():
@pytest.mark.asyncio
async def test_parse_args_nargs_plus():
parser = CommandArgumentParser()
parser.add_argument("files", nargs="+", type=str)
args = parser.parse_args(["a", "b", "c"])
args = await parser.parse_args(["a", "b", "c"])
assert args["files"] == ["a", "b", "c"]
args = parser.parse_args(["a"])
args = await parser.parse_args(["a"])
assert args["files"] == ["a"]
def test_parse_args_flagged_nargs_plus():
@pytest.mark.asyncio
async def test_parse_args_flagged_nargs_plus():
parser = CommandArgumentParser()
parser.add_argument("--files", nargs="+", type=str)
args = parser.parse_args(["--files", "a", "b", "c"])
args = await parser.parse_args(["--files", "a", "b", "c"])
assert args["files"] == ["a", "b", "c"]
args = parser.parse_args(["--files", "a"])
args = await parser.parse_args(["--files", "a"])
print(args)
assert args["files"] == ["a"]
args = parser.parse_args([])
args = await parser.parse_args([])
assert args["files"] == []
def test_parse_args_numbered_nargs():
@pytest.mark.asyncio
async def test_parse_args_numbered_nargs():
parser = CommandArgumentParser()
parser.add_argument("files", nargs=2, type=str)
args = parser.parse_args(["a", "b"])
args = await parser.parse_args(["a", "b"])
assert args["files"] == ["a", "b"]
with pytest.raises(CommandArgumentError):
args = parser.parse_args(["a"])
args = await parser.parse_args(["a"])
print(args)
@ -436,48 +457,53 @@ def test_parse_args_nargs_zero():
parser.add_argument("files", nargs=0, type=str)
def test_parse_args_nargs_more_than_expected():
@pytest.mark.asyncio
async def test_parse_args_nargs_more_than_expected():
parser = CommandArgumentParser()
parser.add_argument("files", nargs=2, type=str)
with pytest.raises(CommandArgumentError):
parser.parse_args(["a", "b", "c", "d"])
await parser.parse_args(["a", "b", "c", "d"])
def test_parse_args_nargs_one_or_none():
@pytest.mark.asyncio
async def test_parse_args_nargs_one_or_none():
parser = CommandArgumentParser()
parser.add_argument("files", nargs="?", type=str)
args = parser.parse_args(["a"])
args = await parser.parse_args(["a"])
assert args["files"] == "a"
args = parser.parse_args([])
args = await parser.parse_args([])
assert args["files"] is None
def test_parse_args_nargs_positional():
@pytest.mark.asyncio
async def test_parse_args_nargs_positional():
parser = CommandArgumentParser()
parser.add_argument("files", nargs="*", type=str)
args = parser.parse_args(["a", "b", "c"])
args = await parser.parse_args(["a", "b", "c"])
assert args["files"] == ["a", "b", "c"]
args = parser.parse_args([])
args = await parser.parse_args([])
assert args["files"] == []
def test_parse_args_nargs_positional_plus():
@pytest.mark.asyncio
async def test_parse_args_nargs_positional_plus():
parser = CommandArgumentParser()
parser.add_argument("files", nargs="+", type=str)
args = parser.parse_args(["a", "b", "c"])
args = await parser.parse_args(["a", "b", "c"])
assert args["files"] == ["a", "b", "c"]
with pytest.raises(CommandArgumentError):
args = parser.parse_args([])
args = await parser.parse_args([])
def test_parse_args_nargs_multiple_positional():
@pytest.mark.asyncio
async def test_parse_args_nargs_multiple_positional():
parser = CommandArgumentParser()
parser.add_argument("files", nargs="+", type=str)
parser.add_argument("mode", nargs=1)
@ -485,7 +511,7 @@ def test_parse_args_nargs_multiple_positional():
parser.add_argument("target", nargs="*")
parser.add_argument("extra", nargs="+")
args = parser.parse_args(["a", "b", "c", "d", "e"])
args = await parser.parse_args(["a", "b", "c", "d", "e"])
assert args["files"] == ["a", "b", "c"]
assert args["mode"] == "d"
assert args["action"] == []
@ -493,186 +519,311 @@ def test_parse_args_nargs_multiple_positional():
assert args["extra"] == ["e"]
with pytest.raises(CommandArgumentError):
parser.parse_args([])
await parser.parse_args([])
def test_parse_args_nargs_invalid_positional_arguments():
@pytest.mark.asyncio
async def test_parse_args_nargs_none():
parser = CommandArgumentParser()
parser.add_argument("numbers", type=int)
parser.add_argument("mode")
await parser.parse_args(["1", "2"])
@pytest.mark.asyncio
async def test_parse_args_nargs_invalid_positional_arguments():
parser = CommandArgumentParser()
parser.add_argument("numbers", nargs="*", type=int)
parser.add_argument("mode", nargs=1)
with pytest.raises(CommandArgumentError):
parser.parse_args(["1", "2", "c", "d"])
await parser.parse_args(["1", "2", "c", "d"])
def test_parse_args_append():
@pytest.mark.asyncio
async def test_parse_args_append():
parser = CommandArgumentParser()
parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int)
args = parser.parse_args(["--numbers", "1", "--numbers", "2", "--numbers", "3"])
args = await parser.parse_args(["--numbers", "1", "--numbers", "2", "--numbers", "3"])
assert args["numbers"] == [1, 2, 3]
args = parser.parse_args(["--numbers", "1"])
args = await parser.parse_args(["--numbers", "1"])
assert args["numbers"] == [1]
args = parser.parse_args([])
args = await parser.parse_args([])
assert args["numbers"] == []
def test_parse_args_nargs_append():
@pytest.mark.asyncio
async def test_parse_args_nargs_int_append():
parser = CommandArgumentParser()
parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int, nargs=1)
args = await parser.parse_args(["--numbers", "1", "--numbers", "2", "--numbers", "3"])
assert args["numbers"] == [[1], [2], [3]]
args = await parser.parse_args(["--numbers", "1"])
assert args["numbers"] == [[1]]
args = await parser.parse_args([])
assert args["numbers"] == []
@pytest.mark.asyncio
async def test_parse_args_nargs_append():
parser = CommandArgumentParser()
parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int, nargs="*")
parser.add_argument("--mode")
args = parser.parse_args(["1", "2", "3", "--mode", "numbers", "4", "5"])
assert args["numbers"] == [[1, 2, 3], [4, 5]]
args = parser.parse_args(["1"])
args = await parser.parse_args(["1"])
assert args["numbers"] == [[1]]
args = parser.parse_args([])
args = await parser.parse_args(["1", "2", "3", "--mode", "numbers", "4", "5"])
assert args["numbers"] == [[1, 2, 3], [4, 5]]
assert args["mode"] == "numbers"
args = await parser.parse_args(["1", "2", "3"])
assert args["numbers"] == [[1, 2, 3]]
args = await parser.parse_args([])
assert args["numbers"] == []
def test_parse_args_append_flagged_invalid_type():
@pytest.mark.asyncio
async def test_parse_args_int_optional_append():
parser = CommandArgumentParser()
parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int)
args = await parser.parse_args(["1"])
assert args["numbers"] == [1]
@pytest.mark.asyncio
async def test_parse_args_int_optional_append_multiple_values():
parser = CommandArgumentParser()
parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int)
with pytest.raises(CommandArgumentError):
await parser.parse_args(["1", "2"])
@pytest.mark.asyncio
async def test_parse_args_nargs_int_positional_append():
parser = CommandArgumentParser()
parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int, nargs=1)
args = await parser.parse_args(["1"])
assert args["numbers"] == [[1]]
with pytest.raises(CommandArgumentError):
await parser.parse_args(["1", "2", "3"])
parser2 = CommandArgumentParser()
parser2.add_argument("numbers", action=ArgumentAction.APPEND, type=int, nargs=2)
args = await parser2.parse_args(["1", "2"])
assert args["numbers"] == [[1, 2]]
with pytest.raises(CommandArgumentError):
await parser2.parse_args(["1", "2", "3"])
@pytest.mark.asyncio
async def test_parse_args_append_flagged_invalid_type():
parser = CommandArgumentParser()
parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int)
with pytest.raises(CommandArgumentError):
parser.parse_args(["--numbers", "a"])
await parser.parse_args(["--numbers", "a"])
def test_append_groups_nargs():
@pytest.mark.asyncio
async def test_append_groups_nargs():
cap = CommandArgumentParser()
cap.add_argument("--item", action=ArgumentAction.APPEND, type=str, nargs=2)
parsed = cap.parse_args(["--item", "a", "b", "--item", "c", "d"])
parsed = await cap.parse_args(["--item", "a", "b", "--item", "c", "d"])
assert parsed["item"] == [["a", "b"], ["c", "d"]]
with pytest.raises(CommandArgumentError):
await cap.parse_args(["--item", "a", "b", "--item", "c"])
def test_extend_flattened():
@pytest.mark.asyncio
async def test_extend_flattened():
cap = CommandArgumentParser()
cap.add_argument("--value", action=ArgumentAction.EXTEND, type=str)
parsed = cap.parse_args(["--value", "x", "--value", "y"])
parsed = await cap.parse_args(["--value", "x", "--value", "y"])
assert parsed["value"] == ["x", "y"]
def test_parse_args_split_order():
@pytest.mark.asyncio
async def test_parse_args_split_order():
cap = CommandArgumentParser()
cap.add_argument("a")
cap.add_argument("--x")
cap.add_argument("b", nargs="*")
args, kwargs = cap.parse_args_split(["1", "--x", "100", "2"])
args, kwargs = await cap.parse_args_split(["1", "--x", "100", "2"])
assert args == ("1", ["2"])
assert kwargs == {"x": "100"}
def test_help_signal_triggers():
@pytest.mark.asyncio
async def test_help_signal_triggers():
parser = CommandArgumentParser()
parser.add_argument("--foo")
with pytest.raises(HelpSignal):
parser.parse_args(["--help"])
await parser.parse_args(["--help"])
def test_empty_parser_defaults():
@pytest.mark.asyncio
async def test_empty_parser_defaults():
parser = CommandArgumentParser()
with pytest.raises(HelpSignal):
parser.parse_args(["--help"])
await parser.parse_args(["--help"])
def test_extend_basic():
@pytest.mark.asyncio
async def test_extend_basic():
parser = CommandArgumentParser()
parser.add_argument("--tag", action=ArgumentAction.EXTEND, type=str)
args = parser.parse_args(["--tag", "a", "--tag", "b", "--tag", "c"])
args = await parser.parse_args(["--tag", "a", "--tag", "b", "--tag", "c"])
assert args["tag"] == ["a", "b", "c"]
def test_extend_nargs_2():
@pytest.mark.asyncio
async def test_extend_nargs_2():
parser = CommandArgumentParser()
parser.add_argument("--pair", action=ArgumentAction.EXTEND, type=str, nargs=2)
args = parser.parse_args(["--pair", "a", "b", "--pair", "c", "d"])
args = await parser.parse_args(["--pair", "a", "b", "--pair", "c", "d"])
assert args["pair"] == ["a", "b", "c", "d"]
def test_extend_nargs_star():
@pytest.mark.asyncio
async def test_extend_nargs_star():
parser = CommandArgumentParser()
parser.add_argument("--files", action=ArgumentAction.EXTEND, type=str, nargs="*")
args = parser.parse_args(["--files", "x", "y", "z"])
args = await parser.parse_args(["--files", "x", "y", "z"])
assert args["files"] == ["x", "y", "z"]
args = parser.parse_args(["--files"])
args = await parser.parse_args(["--files"])
assert args["files"] == []
def test_extend_nargs_plus():
@pytest.mark.asyncio
async def test_extend_nargs_plus():
parser = CommandArgumentParser()
parser.add_argument("--inputs", action=ArgumentAction.EXTEND, type=int, nargs="+")
args = parser.parse_args(["--inputs", "1", "2", "3", "--inputs", "4"])
args = await parser.parse_args(["--inputs", "1", "2", "3", "--inputs", "4"])
assert args["inputs"] == [1, 2, 3, 4]
def test_extend_invalid_type():
@pytest.mark.asyncio
async def test_extend_invalid_type():
parser = CommandArgumentParser()
parser.add_argument("--nums", action=ArgumentAction.EXTEND, type=int)
with pytest.raises(CommandArgumentError):
parser.parse_args(["--nums", "a"])
await parser.parse_args(["--nums", "a"])
def test_greedy_invalid_type():
@pytest.mark.asyncio
async def test_greedy_invalid_type():
parser = CommandArgumentParser()
parser.add_argument("--nums", nargs="*", type=int)
with pytest.raises(CommandArgumentError):
parser.parse_args(["--nums", "a"])
await parser.parse_args(["--nums", "a"])
def test_append_vs_extend_behavior():
@pytest.mark.asyncio
async def test_append_vs_extend_behavior():
parser = CommandArgumentParser()
parser.add_argument("--x", action=ArgumentAction.APPEND, nargs=2)
parser.add_argument("--y", action=ArgumentAction.EXTEND, nargs=2)
args = parser.parse_args(
args = await parser.parse_args(
["--x", "a", "b", "--x", "c", "d", "--y", "1", "2", "--y", "3", "4"]
)
assert args["x"] == [["a", "b"], ["c", "d"]]
assert args["y"] == ["1", "2", "3", "4"]
def test_append_vs_extend_behavior_error():
@pytest.mark.asyncio
async def test_append_vs_extend_behavior_error():
parser = CommandArgumentParser()
parser.add_argument("--x", action=ArgumentAction.APPEND, nargs=2)
parser.add_argument("--y", action=ArgumentAction.EXTEND, nargs=2)
# This should raise an error because the last argument is not a valid pair
with pytest.raises(CommandArgumentError):
parser.parse_args(["--x", "a", "b", "--x", "c", "d", "--y", "1", "2", "--y", "3"])
await parser.parse_args(
["--x", "a", "b", "--x", "c", "d", "--y", "1", "2", "--y", "3"]
)
with pytest.raises(CommandArgumentError):
parser.parse_args(["--x", "a", "b", "--x", "c", "--y", "1", "--y", "3", "4"])
await parser.parse_args(
["--x", "a", "b", "--x", "c", "--y", "1", "--y", "3", "4"]
)
def test_extend_positional():
@pytest.mark.asyncio
async def test_extend_positional():
parser = CommandArgumentParser()
parser.add_argument("files", action=ArgumentAction.EXTEND, type=str, nargs="*")
args = parser.parse_args(["a", "b", "c"])
args = await parser.parse_args(["a", "b", "c"])
assert args["files"] == ["a", "b", "c"]
args = parser.parse_args([])
args = await parser.parse_args([])
assert args["files"] == []
def test_extend_positional_nargs():
@pytest.mark.asyncio
async def test_extend_positional_nargs():
parser = CommandArgumentParser()
parser.add_argument("files", action=ArgumentAction.EXTEND, type=str, nargs="+")
args = parser.parse_args(["a", "b", "c"])
args = await parser.parse_args(["a", "b", "c"])
assert args["files"] == ["a", "b", "c"]
with pytest.raises(CommandArgumentError):
parser.parse_args([])
await parser.parse_args([])
def test_command_argument_parser_equality():
parser1 = CommandArgumentParser()
parser2 = CommandArgumentParser()
parser1.add_argument("--foo", type=str)
parser2.add_argument("--foo", type=str)
assert parser1 == parser2
parser1.add_argument("--bar", type=int)
assert parser1 != parser2
parser2.add_argument("--bar", type=int)
assert parser1 == parser2
assert parser1 != "not a parser"
assert parser1 is not None
assert parser1 != object()
assert parser1.to_definition_list() == parser2.to_definition_list()
assert hash(parser1) == hash(parser2)
@pytest.mark.asyncio
async def test_render_help():
parser = CommandArgumentParser()
parser.add_argument("--foo", type=str, help="Foo help")
parser.add_argument("--bar", action=ArgumentAction.APPEND, type=str, help="Bar help")
assert parser.render_help() is None

View File

@ -1,11 +1,40 @@
import os
import shutil
import sys
import tempfile
from argparse import ArgumentParser, Namespace, _SubParsersAction
from pathlib import Path
import pytest
from falyx.__main__ import bootstrap, find_falyx_config, get_falyx_parsers, run
from falyx.__main__ import (
bootstrap,
find_falyx_config,
get_parsers,
init_callback,
init_config,
main,
)
from falyx.parser import CommandArgumentParser
@pytest.fixture(autouse=True)
def fake_home(monkeypatch):
"""Redirect Path.home() to a temporary directory for all tests."""
temp_home = Path(tempfile.mkdtemp())
monkeypatch.setattr(Path, "home", lambda: temp_home)
yield temp_home
shutil.rmtree(temp_home, ignore_errors=True)
@pytest.fixture(autouse=True)
def setup_teardown():
"""Fixture to set up and tear down the environment for each test."""
cwd = Path.cwd()
yield
for file in cwd.glob("falyx.yaml"):
file.unlink(missing_ok=True)
for file in cwd.glob("falyx.toml"):
file.unlink(missing_ok=True)
def test_find_falyx_config():
@ -52,61 +81,52 @@ def test_bootstrap_with_global_config():
sys.path = sys_path_before
def test_parse_args():
"""Test if the parse_args function works correctly."""
falyx_parsers = get_falyx_parsers()
args = falyx_parsers.parse_args(["init", "test_project"])
@pytest.mark.asyncio
async def test_init_config():
"""Test if the init_config function adds the correct argument."""
parser = CommandArgumentParser()
init_config(parser)
args = await parser.parse_args(["test_project"])
assert args["name"] == "test_project"
assert args.command == "init"
assert args.name == "test_project"
args = falyx_parsers.parse_args(["init-global"])
assert args.command == "init-global"
# Test with default value
args = await parser.parse_args([])
assert args["name"] == "."
def test_run():
"""Test if the run function works correctly."""
falyx_parsers = get_falyx_parsers()
args = falyx_parsers.parse_args(["init", "test_project"])
run(args)
assert args.command == "init"
assert args.name == "test_project"
# Check if the project directory was created
assert Path("test_project").exists()
# Clean up
(Path("test_project") / "falyx.yaml").unlink()
(Path("test_project") / "tasks.py").unlink()
Path("test_project").rmdir()
# Test init-global
args = falyx_parsers.parse_args(["init-global"])
run(args)
# Check if the global config directory was created
def test_init_callback(tmp_path):
"""Test if the init_callback function works correctly."""
# Test project initialization
args = Namespace(command="init", name=str(tmp_path))
init_callback(args)
assert (tmp_path / "falyx.yaml").exists()
def test_init_global_callback():
# Test global initialization
args = Namespace(command="init_global")
init_callback(args)
assert (Path.home() / ".config" / "falyx" / "tasks.py").exists()
assert (Path.home() / ".config" / "falyx" / "falyx.yaml").exists()
# Clean up
(Path.home() / ".config" / "falyx" / "falyx.yaml").unlink()
(Path.home() / ".config" / "falyx" / "tasks.py").unlink()
(Path.home() / ".config" / "falyx").rmdir()
def test_no_bootstrap():
"""Test if the main function works correctly when no config file is found."""
falyx_parsers = get_falyx_parsers()
args = falyx_parsers.parse_args(["list"])
assert run(args) is None
# Check if the task was run
assert args.command == "list"
def test_get_parsers():
"""Test if the get_parsers function returns the correct parsers."""
root_parser, subparsers = get_parsers()
assert isinstance(root_parser, ArgumentParser)
assert isinstance(subparsers, _SubParsersAction)
# Check if the 'init' command is available
init_parser = subparsers.choices.get("init")
assert init_parser is not None
assert "name" == init_parser._get_positional_actions()[0].dest
def test_run_test_project():
"""Test if the main function works correctly with a test project."""
falyx_parsers = get_falyx_parsers()
args = falyx_parsers.parse_args(["init", "test_project"])
run(args)
def test_main():
"""Test if the main function runs with the correct arguments."""
args = falyx_parsers.parse_args(["run", "B"])
os.chdir("test_project")
with pytest.raises(SystemExit):
assert run(args) == "Build complete!"
os.chdir("..")
shutil.rmtree("test_project")
assert not Path("test_project").exists()
sys.argv = ["falyx", "run", "?"]
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 0

Some files were not shown because too many files have changed in this diff Show More