Compare commits
63 Commits
b0c0e7dc16
...
falyx-pars
| Author | SHA1 | Date | |
|---|---|---|---|
|
8db7a9e6dc
|
|||
|
cce92cca09
|
|||
|
dcec792d32
|
|||
|
8ece2a5de6
|
|||
|
30cb8b97b5
|
|||
|
5d8f3aa603
|
|||
|
8ce0ffa18e
|
|||
|
79f7bd6a60
|
|||
|
1ce1b2385b
|
|||
|
06bf0e432c
|
|||
|
169f228c92
|
|||
|
0417a06ee4
|
|||
|
55d581b870
|
|||
|
a25888f316
|
|||
|
8e306b9eaf
|
|||
|
3b2c33d28f
|
|||
|
f37aee568d
|
|||
|
8a0a45e17f
|
|||
|
da38f6d6ee
|
|||
|
7836ff4dfd
|
|||
|
7dca416346
|
|||
|
734f7b5962
|
|||
|
489d730755
|
|||
|
825ff60f08
|
|||
|
fa5e2a4c2c
|
|||
|
de53c889a6
|
|||
|
0319058531
|
|||
|
5769882afd
|
|||
|
7f63e16097
|
|||
|
21402bff9a
|
|||
| fddc3ea8d9 | |||
|
9b9f6434a4
|
|||
|
c15e3afa5e
|
|||
|
dc1764e752
|
|||
|
2288015cf3
|
|||
|
68d7d89d64
|
|||
|
9654b9926c
|
|||
|
294bbc9062
|
|||
|
4c1498121f
|
|||
|
ed42f6488e
|
|||
|
e2f0bf5903
|
|||
|
bb325684ac
|
|||
|
38f5f1e934
|
|||
|
2d1177e820
|
|||
|
3c7ef3eb1c
|
|||
|
53ba6a896a
|
|||
|
b24079ea7e
|
|||
|
ac82076511
|
|||
|
09eeb90dc6
|
|||
|
e3ebc1b17b
|
|||
|
079bc0ee77
|
|||
|
1c97857cb8
|
|||
|
21af003bc7
|
|||
|
1585098513
|
|||
|
3d3a706784
|
|||
|
c2eb854e5a
|
|||
|
8a3c1d6cc8
|
|||
|
f196e38c57
|
|||
|
fb1ffbe9f6
|
|||
|
429b434566
|
|||
|
4f3632bc6b
|
|||
|
ba562168aa
|
|||
|
ddb78bd5a7
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,3 +15,4 @@ build/
|
|||||||
.vscode/
|
.vscode/
|
||||||
coverage.xml
|
coverage.xml
|
||||||
.coverage
|
.coverage
|
||||||
|
.config.json
|
||||||
|
|||||||
158
README.md
158
README.md
@@ -10,7 +10,7 @@
|
|||||||
- ⚙️ Full lifecycle hooks (before, after, success, error, teardown)
|
- ⚙️ Full lifecycle hooks (before, after, success, error, teardown)
|
||||||
- 📊 Execution tracing, logging, and introspection
|
- 📊 Execution tracing, logging, and introspection
|
||||||
- 🧙♂️ Async-first design with Process support
|
- 🧙♂️ 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.
|
> 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:
|
Modern CLI tools deserve the same resilience as production systems. Falyx makes it easy to:
|
||||||
|
|
||||||
- Compose workflows using `Action`, `ChainedAction`, or `ActionGroup`
|
- Compose workflows using `Action`, `ChainedAction`, or `ActionGroup`
|
||||||
- Inject the result of one step into the next (`last_result`)
|
- Inject the result of one step into the next (`last_result` / `auto_inject`)
|
||||||
- Handle flaky operations with retries and exponential backoff
|
- Handle flaky operations with retries, backoff, and jitter
|
||||||
- Roll back safely on failure with structured undo logic
|
- 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
|
- 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 asyncio
|
||||||
import random
|
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
|
# A flaky async step that fails randomly
|
||||||
async def flaky_step():
|
async def flaky_step():
|
||||||
await asyncio.sleep(0.2)
|
await asyncio.sleep(0.2)
|
||||||
if random.random() < 0.5:
|
if random.random() < 0.5:
|
||||||
raise RuntimeError("Random failure!")
|
raise RuntimeError("Random failure!")
|
||||||
|
print("ok")
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
# Create the actions
|
# Create the actions
|
||||||
step1 = Action(name="step_1", action=flaky_step, retry=True)
|
step1 = Action(name="step_1", action=flaky_step)
|
||||||
step2 = Action(name="step_2", action=flaky_step, retry=True)
|
step2 = Action(name="step_2", action=flaky_step)
|
||||||
|
|
||||||
# Chain the actions
|
# Chain the actions
|
||||||
chain = ChainedAction(name="my_pipeline", actions=[step1, step2])
|
chain = ChainedAction(name="my_pipeline", actions=[step1, step2])
|
||||||
@@ -74,9 +77,11 @@ falyx.add_command(
|
|||||||
key="R",
|
key="R",
|
||||||
description="Run My Pipeline",
|
description="Run My Pipeline",
|
||||||
action=chain,
|
action=chain,
|
||||||
logging_hooks=True,
|
|
||||||
preview_before_confirm=True,
|
preview_before_confirm=True,
|
||||||
confirm=True,
|
confirm=True,
|
||||||
|
retry_all=True,
|
||||||
|
spinner=True,
|
||||||
|
style="cyan",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Entry point
|
# Entry point
|
||||||
@@ -85,76 +90,131 @@ if __name__ == "__main__":
|
|||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
❯ python simple.py
|
$ python simple.py
|
||||||
🚀 Falyx Demo
|
🚀 Falyx Demo
|
||||||
|
|
||||||
[R] Run My Pipeline
|
[R] Run My Pipeline
|
||||||
[Y] History [Q] Exit
|
[H] Help [Y] History [X] Exit
|
||||||
|
|
||||||
>
|
>
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
❯ python simple.py run R
|
$ python simple.py run r
|
||||||
Command: 'R' — Run My Pipeline
|
Command: 'R' — Run My Pipeline
|
||||||
└── ⛓ ChainedAction 'my_pipeline'
|
└── ⛓ ChainedAction 'my_pipeline'
|
||||||
├── ⚙ Action 'step_1'
|
├── ⚙ Action 'step_1'
|
||||||
│ ↻ Retries: 3x, delay 1.0s, backoff 2.0x
|
│ ↻ Retries: 3x, delay 1.0s, backoff 2.0x
|
||||||
└── ⚙ Action 'step_2'
|
└── ⚙ Action 'step_2'
|
||||||
↻ Retries: 3x, delay 1.0s, backoff 2.0x
|
↻ Retries: 3x, delay 1.0s, backoff 2.0x
|
||||||
Confirm execution of R — Run My Pipeline (calls `my_pipeline`) [Y/n] y
|
❓ 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!'.
|
[2025-07-20 09:29:35] WARNING Retry attempt 1/3 failed due to 'Random failure!'.
|
||||||
✅ Result: ['ok', 'ok']
|
ok
|
||||||
|
[2025-07-20 09:29:38] WARNING Retry attempt 1/3 failed due to 'Random failure!'.
|
||||||
|
ok
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📦 Core Features
|
## 📦 Core Features
|
||||||
|
|
||||||
- ✅ Async-native `Action`, `ChainedAction`, `ActionGroup`
|
- ✅ Async-native `Action`, `ChainedAction`, `ActionGroup`, `ProcessAction`
|
||||||
- 🔁 Retry policies + exponential backoff
|
- 🔁 Retry policies with delay, backoff, jitter — opt-in per action or globally
|
||||||
- ⛓ Rollbacks on chained failures
|
- ⛓ Rollbacks and lifecycle hooks for chained execution
|
||||||
- 🎛️ Headless or interactive CLI with argparse and prompt_toolkit
|
- 🎛️ Headless or interactive CLI powered by `argparse` + `prompt_toolkit`
|
||||||
- 📊 Built-in execution registry, result tracking, and timing
|
- 📊 In-memory `ExecutionRegistry` with result tracking, timing, and tracebacks
|
||||||
- 🧠 Supports `ProcessAction` for CPU-bound workloads
|
- 🌐 CLI menu construction via config files or Python
|
||||||
- 🧩 Custom `Table` rendering for CLI menu views
|
- ⚡ Bottom bar toggle switches and counters with `Ctrl+<key>` shortcuts
|
||||||
- 🔍 Hook lifecycle: `before`, `on_success`, `on_error`, `after`, `on_teardown`
|
- 🔍 Structured confirmation prompts and help rendering
|
||||||
|
- 🪵 Flexible logging: Rich console for devs, JSON logs for ops
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔍 Execution Trace
|
### 🧰 Building Blocks
|
||||||
|
|
||||||
```bash
|
- **`Action`**: A single unit of async (or sync) logic
|
||||||
[2025-04-14 10:33:22] DEBUG [Step 1] ⚙ flaky_step()
|
- **`ChainedAction`**: Execute a sequence of actions, with rollback and injection
|
||||||
[2025-04-14 10:33:22] INFO [Step 1] 🔁 Retrying (1/3) in 1.0s...
|
- **`ActionGroup`**: Run actions concurrently and collect results
|
||||||
[2025-04-14 10:33:23] DEBUG [Step 1] ✅ Success | Result: ok
|
- **`ProcessAction`**: Use `multiprocessing` for CPU-bound workflows
|
||||||
[2025-04-14 10:33:23] DEBUG [My Pipeline] ✅ Result: ['ok', 'ok']
|
- **`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`
|
```bash
|
||||||
A single async unit of work. Painless retry support.
|
> history
|
||||||
|
📊 Execution History
|
||||||
|
|
||||||
#### `ChainedAction`
|
Index Name Start End Duration Status Result / Exception
|
||||||
Run tasks in sequence. Supports rollback on failure and context propagation.
|
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
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`
|
Inspect result by index:
|
||||||
Run tasks in parallel. Useful for fan-out operations like batch API calls.
|
|
||||||
|
|
||||||
#### `ProcessAction`
|
```bash
|
||||||
Offload CPU-bound work to another process — no extra code needed.
|
> history --result-index 0
|
||||||
|
Action(name='step_1', action=flaky_step, args=(), kwargs={}, retry=True, rollback=False) ():
|
||||||
|
ok
|
||||||
|
```
|
||||||
|
|
||||||
#### `Falyx`
|
Print last result includes tracebacks:
|
||||||
Your CLI controller — powers menus, subcommands, history, bottom bars, and more.
|
|
||||||
|
|
||||||
#### `ExecutionContext`
|
```bash
|
||||||
Tracks metadata, arguments, timing, and results for each action execution.
|
> history --last-result
|
||||||
|
Command(key='R', description='Run My Pipeline' action='ChainedAction(name=my_pipeline, actions=['step_1', 'step_2'],
|
||||||
#### `HookManager`
|
args=(), kwargs={}, auto_inject=False, return_list=False)') ():
|
||||||
Registers and triggers lifecycle hooks (`before`, `after`, `on_error`, etc.) for actions and commands.
|
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.”
|
> “Like a phalanx: organized, resilient, and reliable.”
|
||||||
|
|
||||||
Falyx is designed for developers who don’t just want CLI tools to run — they want them to **fail meaningfully**, **recover gracefully**, and **log clearly**.
|
Falyx is designed for developers who don’t just want CLI tools to run — they want them to **fail meaningfully**, **recover intentionally**, and **log clearly**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from falyx import Action, ActionGroup, ChainedAction
|
from falyx.action import Action, ActionGroup, ChainedAction
|
||||||
|
|
||||||
|
|
||||||
# Actions can be defined as synchronous functions
|
# Actions can be defined as synchronous functions
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from falyx import Falyx
|
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)
|
# Selection of a post ID to fetch (just an example set)
|
||||||
post_selector = SelectionAction(
|
post_selector = SelectionAction(
|
||||||
name="Pick Post ID",
|
name="Pick Post ID",
|
||||||
selections=["1", "2", "3", "4", "5"],
|
selections=["15", "25", "35", "45", "55"],
|
||||||
title="Choose a Post ID to submit",
|
title="Choose a Post ID to submit",
|
||||||
prompt_message="Post ID > ",
|
prompt_message="Post ID > ",
|
||||||
show_table=True,
|
show_table=True,
|
||||||
@@ -14,7 +14,7 @@ post_selector = SelectionAction(
|
|||||||
|
|
||||||
|
|
||||||
# Factory that builds and executes the actual HTTP POST request
|
# 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}")
|
print(f"Building HTTPAction for Post ID: {post_id}")
|
||||||
return HTTPAction(
|
return HTTPAction(
|
||||||
name=f"POST to /posts (id={post_id})",
|
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",
|
name="Build HTTPAction from Post ID",
|
||||||
factory=build_post_action,
|
factory=build_post_action,
|
||||||
inject_last_result=True,
|
inject_last_result=True,
|
||||||
|
|||||||
191
examples/argument_examples.py
Normal file
191
examples/argument_examples.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import asyncio
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
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",
|
||||||
|
path: Path | None = None,
|
||||||
|
tag: str | None = None,
|
||||||
|
verbose: bool | None = None,
|
||||||
|
numbers: list[int] | None = None,
|
||||||
|
just_a_bool: bool = False,
|
||||||
|
) -> str:
|
||||||
|
if numbers is None:
|
||||||
|
numbers = []
|
||||||
|
if verbose:
|
||||||
|
print(
|
||||||
|
f"Deploying {service}:{tag}:{"|".join(str(number) for number in numbers)} to {region} at {place} from {path}..."
|
||||||
|
)
|
||||||
|
return f"{service}:{tag}:{"|".join(str(number) for number in numbers)} deployed to {region} at {place} from {path}."
|
||||||
|
|
||||||
|
|
||||||
|
async def test_path_arg(*paths: Path) -> str:
|
||||||
|
return f"Path argument received: {'|'.join(str(path) for path in paths)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_positional_numbers(*numbers: int) -> str:
|
||||||
|
return f"Positional numbers received: {', '.join(str(num) for num in numbers)}"
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
||||||
|
"-p",
|
||||||
|
"--path",
|
||||||
|
type=Path,
|
||||||
|
help="Path to the configuration file.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--verbose",
|
||||||
|
action="store_bool_optional",
|
||||||
|
help="Enable verbose output.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-t",
|
||||||
|
"--tag",
|
||||||
|
type=str,
|
||||||
|
help="Optional tag for the deployment.",
|
||||||
|
suggestions=["latest", "stable", "beta"],
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--numbers",
|
||||||
|
type=int,
|
||||||
|
nargs="*",
|
||||||
|
default=[1, 2, 3],
|
||||||
|
help="Optional number argument.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-j",
|
||||||
|
"--just-a-bool",
|
||||||
|
action="store_true",
|
||||||
|
help="Just a boolean flag.",
|
||||||
|
)
|
||||||
|
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"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def path_config(parser: CommandArgumentParser) -> None:
|
||||||
|
"""Argument configuration for path testing command."""
|
||||||
|
parser.add_argument(
|
||||||
|
"paths",
|
||||||
|
type=Path,
|
||||||
|
nargs="*",
|
||||||
|
help="One or more file or directory paths.",
|
||||||
|
)
|
||||||
|
parser.add_tldr_examples(
|
||||||
|
[
|
||||||
|
("/path/to/file.txt", "Single file path"),
|
||||||
|
("/path/to/dir1 /path/to/dir2", "Multiple directory paths"),
|
||||||
|
("/path/with spaces/file.txt", "Path with spaces"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def numbers_config(parser: CommandArgumentParser) -> None:
|
||||||
|
"""Argument configuration for positional numbers testing command."""
|
||||||
|
parser.add_argument(
|
||||||
|
"numbers",
|
||||||
|
type=int,
|
||||||
|
nargs="*",
|
||||||
|
help="One or more integers.",
|
||||||
|
)
|
||||||
|
parser.add_tldr_examples(
|
||||||
|
[
|
||||||
|
("1 2 3", "Three numbers"),
|
||||||
|
("42", "Single number"),
|
||||||
|
("", "No numbers"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
flx.add_command(
|
||||||
|
key="P",
|
||||||
|
aliases=["path"],
|
||||||
|
description="Path Command",
|
||||||
|
help_text="A command to test path argument parsing.",
|
||||||
|
action=Action(
|
||||||
|
name="test_path_arg",
|
||||||
|
action=test_path_arg,
|
||||||
|
),
|
||||||
|
style="bold #F2B3EB",
|
||||||
|
argument_config=path_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
flx.add_command(
|
||||||
|
key="N",
|
||||||
|
aliases=["numbers"],
|
||||||
|
description="Numbers Command",
|
||||||
|
help_text="A command to test positional numbers argument parsing.",
|
||||||
|
action=Action(
|
||||||
|
name="test_positional_numbers",
|
||||||
|
action=test_positional_numbers,
|
||||||
|
),
|
||||||
|
style="bold #F2F2B3",
|
||||||
|
argument_config=numbers_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(flx.run())
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from falyx import Action, ActionGroup, Command, Falyx
|
from falyx import Falyx
|
||||||
|
from falyx.action import Action, ActionGroup
|
||||||
|
|
||||||
|
|
||||||
# Define a shared async function
|
# Define a shared async function
|
||||||
@@ -19,10 +20,11 @@ action3 = Action("say_hello_3", action=say_hello)
|
|||||||
# Combine into an ActionGroup
|
# Combine into an ActionGroup
|
||||||
group = ActionGroup(name="greet_group", actions=[action1, action2, action3])
|
group = ActionGroup(name="greet_group", actions=[action1, action2, action3])
|
||||||
|
|
||||||
# Create the Command with auto_args=True
|
flx = Falyx("Test Group")
|
||||||
cmd = Command(
|
flx.add_command(
|
||||||
key="G",
|
key="G",
|
||||||
description="Greet someone with multiple variations.",
|
description="Greet someone with multiple variations.",
|
||||||
|
aliases=["greet", "hello"],
|
||||||
action=group,
|
action=group,
|
||||||
arg_metadata={
|
arg_metadata={
|
||||||
"name": {
|
"name": {
|
||||||
@@ -33,7 +35,4 @@ cmd = Command(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
flx = Falyx("Test Group")
|
|
||||||
flx.add_command_from_command(cmd)
|
|
||||||
asyncio.run(flx.run())
|
asyncio.run(flx.run())
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from falyx import Action, ChainedAction, Falyx
|
from falyx import Falyx
|
||||||
|
from falyx.action import Action, ChainedAction
|
||||||
from falyx.utils import setup_logging
|
from falyx.utils import setup_logging
|
||||||
|
|
||||||
setup_logging()
|
setup_logging()
|
||||||
@@ -20,16 +21,21 @@ flx = Falyx("Deployment CLI")
|
|||||||
flx.add_command(
|
flx.add_command(
|
||||||
key="D",
|
key="D",
|
||||||
aliases=["deploy"],
|
aliases=["deploy"],
|
||||||
description="Deploy a service to a specified region.",
|
description="Deploy",
|
||||||
|
help_text="Deploy a service to a specified region.",
|
||||||
action=Action(
|
action=Action(
|
||||||
name="deploy_service",
|
name="deploy_service",
|
||||||
action=deploy,
|
action=deploy,
|
||||||
),
|
),
|
||||||
arg_metadata={
|
arg_metadata={
|
||||||
"service": "Service name",
|
"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"},
|
"verbose": {"help": "Enable verbose mode"},
|
||||||
},
|
},
|
||||||
|
tags=["deployment", "service"],
|
||||||
)
|
)
|
||||||
|
|
||||||
deploy_chain = ChainedAction(
|
deploy_chain = ChainedAction(
|
||||||
@@ -47,8 +53,10 @@ deploy_chain = ChainedAction(
|
|||||||
flx.add_command(
|
flx.add_command(
|
||||||
key="N",
|
key="N",
|
||||||
aliases=["notify"],
|
aliases=["notify"],
|
||||||
description="Deploy a service and notify.",
|
description="Deploy and Notify",
|
||||||
|
help_text="Deploy a service and notify.",
|
||||||
action=deploy_chain,
|
action=deploy_chain,
|
||||||
|
tags=["deployment", "service", "notification"],
|
||||||
)
|
)
|
||||||
|
|
||||||
asyncio.run(flx.run())
|
asyncio.run(flx.run())
|
||||||
|
|||||||
127
examples/confirm_example.py
Normal file
127
examples/confirm_example.py
Normal 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())
|
||||||
@@ -3,7 +3,7 @@ commands:
|
|||||||
description: Pipeline Demo
|
description: Pipeline Demo
|
||||||
action: pipeline_demo.pipeline
|
action: pipeline_demo.pipeline
|
||||||
tags: [pipeline, demo]
|
tags: [pipeline, demo]
|
||||||
help_text: Run Demployment Pipeline with retries.
|
help_text: Run Deployment Pipeline with retries.
|
||||||
|
|
||||||
- key: G
|
- key: G
|
||||||
description: Run HTTP Action Group
|
description: Run HTTP Action Group
|
||||||
|
|||||||
@@ -7,11 +7,9 @@ Licensed under the MIT License. See LICENSE file for details.
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import random
|
import random
|
||||||
from argparse import Namespace
|
|
||||||
|
|
||||||
from falyx.action import Action, ActionGroup, ChainedAction
|
from falyx.action import Action, ActionGroup, ChainedAction
|
||||||
from falyx.falyx import Falyx
|
from falyx.falyx import Falyx
|
||||||
from falyx.parsers import FalyxParsers, get_arg_parsers
|
|
||||||
from falyx.version import __version__
|
from falyx.version import __version__
|
||||||
|
|
||||||
|
|
||||||
@@ -74,17 +72,10 @@ class Foo:
|
|||||||
await self.flx.run()
|
await self.flx.run()
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> Namespace:
|
|
||||||
parsers: FalyxParsers = get_arg_parsers()
|
|
||||||
return parsers.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
"""Build and return a Falyx instance with all your commands."""
|
"""Build and return a Falyx instance with all your commands."""
|
||||||
args = parse_args()
|
|
||||||
flx = Falyx(
|
flx = Falyx(
|
||||||
title="🚀 Falyx CLI",
|
title="🚀 Falyx CLI",
|
||||||
cli_args=args,
|
|
||||||
columns=5,
|
columns=5,
|
||||||
welcome_message="Welcome to Falyx CLI!",
|
welcome_message="Welcome to Falyx CLI!",
|
||||||
exit_message="Goodbye!",
|
exit_message="Goodbye!",
|
||||||
@@ -93,7 +84,7 @@ async def main() -> None:
|
|||||||
|
|
||||||
# --- Bottom bar info ---
|
# --- Bottom bar info ---
|
||||||
flx.bottom_bar.columns = 3
|
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_toggle_from_option("U", "Debug Hooks", flx.options, "debug_hooks")
|
||||||
flx.bottom_bar.add_static("Version", f"Falyx v{__version__}")
|
flx.bottom_bar.add_static("Version", f"Falyx v{__version__}")
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,26 @@ import asyncio
|
|||||||
|
|
||||||
from falyx import Falyx
|
from falyx import Falyx
|
||||||
from falyx.action import SelectFileAction
|
from falyx.action import SelectFileAction
|
||||||
from falyx.action.types import FileReturnType
|
from falyx.action.action_types import FileType
|
||||||
|
|
||||||
sf = SelectFileAction(
|
sf = SelectFileAction(
|
||||||
name="select_file",
|
name="select_file",
|
||||||
suffix_filter=".py",
|
suffix_filter=".yaml",
|
||||||
title="Select a YAML file",
|
title="Select a YAML file",
|
||||||
prompt_message="Choose > ",
|
prompt_message="Choose 2 > ",
|
||||||
return_type=FileReturnType.TEXT,
|
return_type=FileType.TEXT,
|
||||||
columns=3,
|
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(
|
flx.add_command(
|
||||||
key="S",
|
key="S",
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import asyncio
|
|||||||
|
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
from falyx import ActionGroup, Falyx
|
from falyx import Falyx
|
||||||
from falyx.action import HTTPAction
|
from falyx.action import ActionGroup, HTTPAction
|
||||||
from falyx.hooks import ResultReporter
|
from falyx.hooks import ResultReporter
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|||||||
@@ -2,8 +2,16 @@ import asyncio
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from falyx import Falyx
|
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.menu import MenuOption, MenuOptionMap
|
||||||
|
from falyx.themes import OneColors
|
||||||
|
|
||||||
|
|
||||||
# Basic coroutine for Action
|
# Basic coroutine for Action
|
||||||
@@ -77,20 +85,28 @@ parallel = ActionGroup(
|
|||||||
|
|
||||||
process = ProcessAction(name="compute", action=heavy_computation)
|
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 setup
|
||||||
|
|
||||||
menu = MenuAction(
|
menu = MenuAction(
|
||||||
name="main-menu",
|
name="main-menu",
|
||||||
title="Choose a task to run",
|
title="Choose a task to run",
|
||||||
menu_options=MenuOptionMap(
|
menu_options=menu_options,
|
||||||
{
|
)
|
||||||
"1": MenuOption("Run basic Action", basic_action),
|
|
||||||
"2": MenuOption("Run ChainedAction", chained),
|
|
||||||
"3": MenuOption("Run ActionGroup (parallel)", parallel),
|
prompt_menu = PromptMenuAction(
|
||||||
"4": MenuOption("Run ProcessAction (heavy task)", process),
|
name="select-user",
|
||||||
}
|
menu_options=menu_options,
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
flx = Falyx(
|
flx = Falyx(
|
||||||
@@ -108,6 +124,13 @@ flx.add_command(
|
|||||||
logging_hooks=True,
|
logging_hooks=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
flx.add_command(
|
||||||
|
key="P",
|
||||||
|
description="Show Prompt Menu",
|
||||||
|
action=prompt_menu,
|
||||||
|
logging_hooks=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
asyncio.run(flx.run())
|
asyncio.run(flx.run())
|
||||||
|
|||||||
@@ -1,60 +1,92 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
|
||||||
from falyx import Action, ActionGroup, ChainedAction
|
from falyx import Falyx
|
||||||
from falyx import ExecutionRegistry as er
|
from falyx.action import Action, ActionGroup, ChainedAction, ProcessAction
|
||||||
from falyx import ProcessAction
|
from falyx.console import console
|
||||||
from falyx.retry import RetryHandler, RetryPolicy
|
|
||||||
|
|
||||||
|
|
||||||
# Step 1: Fast I/O-bound setup (standard Action)
|
# Step 1: Fast I/O-bound setup (standard Action)
|
||||||
async def checkout_code():
|
async def checkout_code():
|
||||||
print("📥 Checking out code...")
|
console.print("🔄 Checking out code...")
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
|
console.print("📦 Code checked out successfully.")
|
||||||
|
|
||||||
|
|
||||||
# Step 2: CPU-bound task (ProcessAction)
|
# Step 2: CPU-bound task (ProcessAction)
|
||||||
def run_static_analysis():
|
def run_static_analysis():
|
||||||
print("🧠 Running static analysis (CPU-bound)...")
|
|
||||||
total = 0
|
total = 0
|
||||||
for i in range(10_000_000):
|
for i in range(10_000_000):
|
||||||
total += i % 3
|
total += i % 3
|
||||||
|
time.sleep(2)
|
||||||
return total
|
return total
|
||||||
|
|
||||||
|
|
||||||
# Step 3: Simulated flaky test with retry
|
# Step 3: Simulated flaky test with retry
|
||||||
async def flaky_tests():
|
async def flaky_tests():
|
||||||
import random
|
console.print("🧪 Running tests...")
|
||||||
|
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
if random.random() < 0.3:
|
if random.random() < 0.3:
|
||||||
raise RuntimeError("❌ Random test failure!")
|
raise RuntimeError("❌ Random test failure!")
|
||||||
print("🧪 Tests passed.")
|
console.print("🧪 Tests passed.")
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
# Step 4: Multiple deploy targets (parallel ActionGroup)
|
# Step 4: Multiple deploy targets (parallel ActionGroup)
|
||||||
async def deploy_to(target: str):
|
async def deploy_to(target: str):
|
||||||
print(f"🚀 Deploying to {target}...")
|
console.print(f"🚀 Deploying to {target}...")
|
||||||
await asyncio.sleep(0.2)
|
await asyncio.sleep(random.randint(2, 6))
|
||||||
|
console.print(f"✅ Deployment to {target} complete.")
|
||||||
return f"{target} complete"
|
return f"{target} complete"
|
||||||
|
|
||||||
|
|
||||||
def build_pipeline():
|
def build_pipeline():
|
||||||
retry_handler = RetryHandler(RetryPolicy(max_retries=3, delay=0.5))
|
|
||||||
|
|
||||||
# Base actions
|
# Base actions
|
||||||
checkout = Action("Checkout", checkout_code)
|
checkout = Action("Checkout", checkout_code)
|
||||||
analysis = ProcessAction("Static Analysis", run_static_analysis)
|
analysis = ProcessAction(
|
||||||
tests = Action("Run Tests", flaky_tests)
|
"Static Analysis",
|
||||||
tests.hooks.register("on_error", retry_handler.retry_on_error)
|
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
|
# Parallel deploys
|
||||||
deploy_group = ActionGroup(
|
deploy_group = ActionGroup(
|
||||||
"Deploy to All",
|
"Deploy to All",
|
||||||
[
|
[
|
||||||
Action("Deploy US", deploy_to, args=("us-west",)),
|
Action(
|
||||||
Action("Deploy EU", deploy_to, args=("eu-central",)),
|
"Deploy US",
|
||||||
Action("Deploy Asia", deploy_to, args=("asia-east",)),
|
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...",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -67,10 +99,22 @@ pipeline = build_pipeline()
|
|||||||
|
|
||||||
# Run the pipeline
|
# Run the pipeline
|
||||||
async def main():
|
async def main():
|
||||||
pipeline = build_pipeline()
|
|
||||||
await pipeline()
|
flx = Falyx(
|
||||||
er.summary()
|
hide_menu_table=True, program="pipeline_demo.py", show_placeholder_menu=True
|
||||||
await pipeline.preview()
|
)
|
||||||
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,25 +1,36 @@
|
|||||||
from rich.console import Console
|
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
|
from falyx.themes import NordColors as nc
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
falyx = Falyx(title="🚀 Process Pool Demo")
|
falyx = Falyx(title="🚀 Process Pool Demo")
|
||||||
|
|
||||||
|
|
||||||
def generate_primes(n):
|
def generate_primes(start: int = 2, end: int = 100_000) -> list[int]:
|
||||||
primes = []
|
primes: list[int] = []
|
||||||
for num in range(2, n):
|
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):
|
if all(num % p != 0 for p in primes):
|
||||||
primes.append(num)
|
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
|
return primes
|
||||||
|
|
||||||
|
|
||||||
# Will not block the event loop
|
actions = [ProcessTask(task=generate_primes)]
|
||||||
heavy_action = ProcessAction("Prime Generator", generate_primes, args=(100_000,))
|
|
||||||
|
|
||||||
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from falyx import Action, Falyx
|
from falyx import Falyx
|
||||||
|
from falyx.action import Action
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from falyx import Falyx
|
||||||
from falyx.action import SelectionAction
|
from falyx.action import SelectionAction
|
||||||
from falyx.selection import SelectionOption
|
from falyx.selection import SelectionOption
|
||||||
|
from falyx.signals import CancelSignal
|
||||||
|
|
||||||
selections = {
|
selections = {
|
||||||
"1": SelectionOption(
|
"1": SelectionOption(
|
||||||
@@ -23,4 +26,45 @@ select = SelectionAction(
|
|||||||
show_table=True,
|
show_table=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
print(asyncio.run(select()))
|
list_selections = [uuid4() for _ in range(10)]
|
||||||
|
|
||||||
|
list_select = SelectionAction(
|
||||||
|
name="Select Deployments",
|
||||||
|
selections=list_selections,
|
||||||
|
title="Select Deployments",
|
||||||
|
columns=3,
|
||||||
|
prompt_message="Select 3 Deployments > ",
|
||||||
|
return_type="value",
|
||||||
|
show_table=True,
|
||||||
|
number_selections=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
flx = Falyx()
|
||||||
|
|
||||||
|
flx.add_command(
|
||||||
|
key="S",
|
||||||
|
description="Select a deployment",
|
||||||
|
action=select,
|
||||||
|
help_text="Select a deployment from the list",
|
||||||
|
)
|
||||||
|
flx.add_command(
|
||||||
|
key="L",
|
||||||
|
description="Select deployments",
|
||||||
|
action=list_select,
|
||||||
|
help_text="Select multiple deployments from the list",
|
||||||
|
)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
|
||||||
|
try:
|
||||||
|
print(asyncio.run(select()))
|
||||||
|
except CancelSignal:
|
||||||
|
print("Selection was cancelled.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
print(asyncio.run(list_select()))
|
||||||
|
except CancelSignal:
|
||||||
|
print("Selection was cancelled.")
|
||||||
|
|
||||||
|
asyncio.run(flx.run())
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from falyx import Action, ChainedAction, Falyx
|
from falyx import Falyx
|
||||||
from falyx.action import ShellAction
|
from falyx.action import Action, ChainedAction, ShellAction
|
||||||
from falyx.hooks import ResultReporter
|
from falyx.hooks import ResultReporter
|
||||||
from falyx.utils import setup_logging
|
from falyx.utils import setup_logging
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import random
|
import random
|
||||||
|
|
||||||
from falyx import Action, ChainedAction, Falyx
|
from falyx import Falyx
|
||||||
|
from falyx.action import Action, ChainedAction
|
||||||
from falyx.utils import setup_logging
|
from falyx.utils import setup_logging
|
||||||
|
|
||||||
setup_logging()
|
setup_logging()
|
||||||
@@ -10,15 +11,15 @@ setup_logging()
|
|||||||
# A flaky async step that fails randomly
|
# A flaky async step that fails randomly
|
||||||
async def flaky_step() -> str:
|
async def flaky_step() -> str:
|
||||||
await asyncio.sleep(0.2)
|
await asyncio.sleep(0.2)
|
||||||
if random.random() < 0.3:
|
if random.random() < 0.5:
|
||||||
raise RuntimeError("Random failure!")
|
raise RuntimeError("Random failure!")
|
||||||
print("Flaky step succeeded!")
|
print("ok")
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
# Create a retry handler
|
# Create a retry handler
|
||||||
step1 = Action(name="step_1", action=flaky_step, retry=True)
|
step1 = Action(name="step_1", action=flaky_step)
|
||||||
step2 = Action(name="step_2", action=flaky_step, retry=True)
|
step2 = Action(name="step_2", action=flaky_step)
|
||||||
|
|
||||||
# Chain the actions
|
# Chain the actions
|
||||||
chain = ChainedAction(name="my_pipeline", actions=[step1, step2])
|
chain = ChainedAction(name="my_pipeline", actions=[step1, step2])
|
||||||
@@ -32,6 +33,8 @@ falyx.add_command(
|
|||||||
logging_hooks=True,
|
logging_hooks=True,
|
||||||
preview_before_confirm=True,
|
preview_before_confirm=True,
|
||||||
confirm=True,
|
confirm=True,
|
||||||
|
retry_all=True,
|
||||||
|
spinner=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Entry point
|
# Entry point
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import random
|
import random
|
||||||
|
|
||||||
from falyx import Action, ChainedAction, Falyx
|
from falyx import Falyx
|
||||||
|
from falyx.action import Action, ChainedAction
|
||||||
from falyx.utils import setup_logging
|
from falyx.utils import setup_logging
|
||||||
|
|
||||||
setup_logging()
|
setup_logging()
|
||||||
|
|||||||
100
examples/type_validation.py
Normal file
100
examples/type_validation.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import asyncio
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from falyx import Falyx
|
||||||
|
from falyx.parser import CommandArgumentParser
|
||||||
|
|
||||||
|
flx = Falyx("Test Type Validation")
|
||||||
|
|
||||||
|
|
||||||
|
def uuid_val(value: str) -> str:
|
||||||
|
"""Custom validator to ensure a string is a valid UUID."""
|
||||||
|
UUID(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
async def print_uuid(uuid: str) -> str:
|
||||||
|
"""Prints the UUID if valid."""
|
||||||
|
print(f"Valid UUID: {uuid}")
|
||||||
|
return uuid
|
||||||
|
|
||||||
|
|
||||||
|
flx.add_command(
|
||||||
|
"U",
|
||||||
|
"Print a valid UUID (arguemnts)",
|
||||||
|
print_uuid,
|
||||||
|
arguments=[
|
||||||
|
{
|
||||||
|
"flags": ["uuid"],
|
||||||
|
"type": uuid_val,
|
||||||
|
"help": "A valid UUID string",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def uuid_parser(parser: CommandArgumentParser) -> None:
|
||||||
|
"""Custom parser to ensure the UUID argument is valid."""
|
||||||
|
parser.add_argument(
|
||||||
|
"uuid",
|
||||||
|
type=uuid_val,
|
||||||
|
help="A valid UUID string",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
flx.add_command(
|
||||||
|
"I",
|
||||||
|
"Print a valid UUID (argument_config)",
|
||||||
|
print_uuid,
|
||||||
|
argument_config=uuid_parser,
|
||||||
|
)
|
||||||
|
|
||||||
|
flx.add_command(
|
||||||
|
"D",
|
||||||
|
"Print a valid UUID (arg_metadata)",
|
||||||
|
print_uuid,
|
||||||
|
arg_metadata={
|
||||||
|
"uuid": {
|
||||||
|
"type": uuid_val,
|
||||||
|
"help": "A valid UUID string",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def custom_parser(arguments: list[str]) -> tuple[tuple, dict]:
|
||||||
|
"""Custom parser to ensure the UUID argument is valid."""
|
||||||
|
if len(arguments) != 1:
|
||||||
|
raise ValueError("Exactly one argument is required")
|
||||||
|
uuid_val(arguments[0])
|
||||||
|
return (arguments[0],), {}
|
||||||
|
|
||||||
|
|
||||||
|
flx.add_command(
|
||||||
|
"C",
|
||||||
|
"Print a valid UUID (custom_parser)",
|
||||||
|
print_uuid,
|
||||||
|
custom_parser=custom_parser,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_uuid() -> str:
|
||||||
|
"""Generates a new UUID."""
|
||||||
|
new_uuid = uuid4()
|
||||||
|
print(f"Generated UUID: {new_uuid}")
|
||||||
|
return new_uuid
|
||||||
|
|
||||||
|
|
||||||
|
flx.add_command(
|
||||||
|
"G",
|
||||||
|
"Generate a new UUID",
|
||||||
|
lambda: print(uuid4()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
await flx.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -22,7 +22,7 @@ chain = ChainedAction(
|
|||||||
"Name",
|
"Name",
|
||||||
UserInputAction(
|
UserInputAction(
|
||||||
name="User Input",
|
name="User Input",
|
||||||
prompt_text="Enter your {last_result}: ",
|
prompt_message="Enter your {last_result}: ",
|
||||||
validator=validate_alpha(),
|
validator=validate_alpha(),
|
||||||
),
|
),
|
||||||
Action(
|
Action(
|
||||||
|
|||||||
@@ -1,29 +1,18 @@
|
|||||||
"""
|
"""Falyx CLI Framework
|
||||||
Falyx CLI Framework
|
|
||||||
|
|
||||||
Copyright (c) 2025 rtj.dev LLC.
|
Copyright (c) 2026 rtj.dev LLC.
|
||||||
Licensed under the MIT License. See LICENSE file for details.
|
Licensed under the MIT License. See LICENSE file for details.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from .action.action import Action, ActionGroup, ChainedAction, ProcessAction
|
|
||||||
from .command import Command
|
|
||||||
from .context import ExecutionContext, SharedContext
|
|
||||||
from .execution_registry import ExecutionRegistry
|
from .execution_registry import ExecutionRegistry
|
||||||
from .falyx import Falyx
|
from .falyx import Falyx
|
||||||
|
|
||||||
logger = logging.getLogger("falyx")
|
logger = logging.getLogger("falyx")
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Action",
|
|
||||||
"ChainedAction",
|
|
||||||
"ActionGroup",
|
|
||||||
"ProcessAction",
|
|
||||||
"Falyx",
|
"Falyx",
|
||||||
"Command",
|
|
||||||
"ExecutionContext",
|
|
||||||
"SharedContext",
|
|
||||||
"ExecutionRegistry",
|
"ExecutionRegistry",
|
||||||
"HookType",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
"""
|
"""Falyx CLI Framework
|
||||||
Falyx CLI Framework
|
|
||||||
|
|
||||||
Copyright (c) 2025 rtj.dev LLC.
|
Copyright (c) 2026 rtj.dev LLC.
|
||||||
Licensed under the MIT License. See LICENSE file for details.
|
Licensed under the MIT License. See LICENSE file for details.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from argparse import Namespace
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from falyx.config import loader
|
from falyx.config import loader
|
||||||
from falyx.falyx import Falyx
|
from falyx.falyx import Falyx
|
||||||
from falyx.parsers import FalyxParsers, get_arg_parsers
|
from falyx.parser import CommandArgumentParser
|
||||||
|
|
||||||
|
|
||||||
def find_falyx_config() -> Path | None:
|
def find_falyx_config() -> Path | None:
|
||||||
@@ -39,45 +37,50 @@ def bootstrap() -> Path | None:
|
|||||||
return config_path
|
return config_path
|
||||||
|
|
||||||
|
|
||||||
def get_falyx_parsers() -> FalyxParsers:
|
def init_config(parser: CommandArgumentParser) -> None:
|
||||||
falyx_parsers: FalyxParsers = get_arg_parsers()
|
parser.add_argument(
|
||||||
init_parser = falyx_parsers.subparsers.add_parser(
|
"name",
|
||||||
"init", help="Create a new Falyx CLI project"
|
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"
|
def build_bootstrap_falyx() -> Falyx:
|
||||||
|
from falyx.init import init_global, init_project
|
||||||
|
|
||||||
|
flx = 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.",
|
||||||
)
|
)
|
||||||
return falyx_parsers
|
flx.add_command(
|
||||||
|
"G",
|
||||||
|
"Initialize Falyx global configuration",
|
||||||
|
init_global,
|
||||||
|
aliases=["init-global"],
|
||||||
|
help_text="Create a global Falyx configuration at ~/.config/falyx/.",
|
||||||
|
)
|
||||||
|
return flx
|
||||||
|
|
||||||
|
|
||||||
def run(args: Namespace) -> Any:
|
def build_falyx() -> Falyx:
|
||||||
if args.command == "init":
|
|
||||||
from falyx.init import init_project
|
|
||||||
|
|
||||||
init_project(args.name)
|
|
||||||
return
|
|
||||||
|
|
||||||
if args.command == "init-global":
|
|
||||||
from falyx.init import init_global
|
|
||||||
|
|
||||||
init_global()
|
|
||||||
return
|
|
||||||
|
|
||||||
bootstrap_path = bootstrap()
|
bootstrap_path = bootstrap()
|
||||||
if not bootstrap_path:
|
if bootstrap_path:
|
||||||
print("No Falyx config file found. Exiting.")
|
return loader(bootstrap_path)
|
||||||
return None
|
return build_bootstrap_falyx()
|
||||||
|
|
||||||
flx: Falyx = loader(bootstrap_path)
|
|
||||||
|
def main() -> Any:
|
||||||
|
flx = build_falyx()
|
||||||
return asyncio.run(flx.run())
|
return asyncio.run(flx.run())
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parsers = get_falyx_parsers()
|
|
||||||
args = parsers.parse_args()
|
|
||||||
run(args)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -1,25 +1,28 @@
|
|||||||
"""
|
"""Falyx CLI Framework
|
||||||
Falyx CLI Framework
|
|
||||||
|
|
||||||
Copyright (c) 2025 rtj.dev LLC.
|
Copyright (c) 2026 rtj.dev LLC.
|
||||||
Licensed under the MIT License. See LICENSE file for details.
|
Licensed under the MIT License. See LICENSE file for details.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .action import (
|
from .action import Action
|
||||||
Action,
|
from .action_factory import ActionFactory
|
||||||
ActionGroup,
|
from .action_group import ActionGroup
|
||||||
BaseAction,
|
from .base_action import BaseAction
|
||||||
ChainedAction,
|
from .chained_action import ChainedAction
|
||||||
FallbackAction,
|
from .confirm_action import ConfirmAction
|
||||||
LiteralInputAction,
|
from .fallback_action import FallbackAction
|
||||||
ProcessAction,
|
|
||||||
)
|
|
||||||
from .action_factory import ActionFactoryAction
|
|
||||||
from .http_action import HTTPAction
|
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 .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 .select_file_action import SelectFileAction
|
||||||
from .selection_action import SelectionAction
|
from .selection_action import SelectionAction
|
||||||
|
from .shell_action import ShellAction
|
||||||
from .signal_action import SignalAction
|
from .signal_action import SignalAction
|
||||||
from .user_input_action import UserInputAction
|
from .user_input_action import UserInputAction
|
||||||
|
|
||||||
@@ -29,7 +32,7 @@ __all__ = [
|
|||||||
"BaseAction",
|
"BaseAction",
|
||||||
"ChainedAction",
|
"ChainedAction",
|
||||||
"ProcessAction",
|
"ProcessAction",
|
||||||
"ActionFactoryAction",
|
"ActionFactory",
|
||||||
"HTTPAction",
|
"HTTPAction",
|
||||||
"BaseIOAction",
|
"BaseIOAction",
|
||||||
"ShellAction",
|
"ShellAction",
|
||||||
@@ -40,4 +43,9 @@ __all__ = [
|
|||||||
"FallbackAction",
|
"FallbackAction",
|
||||||
"LiteralInputAction",
|
"LiteralInputAction",
|
||||||
"UserInputAction",
|
"UserInputAction",
|
||||||
|
"PromptMenuAction",
|
||||||
|
"ProcessPoolAction",
|
||||||
|
"LoadFileAction",
|
||||||
|
"SaveFileAction",
|
||||||
|
"ConfirmAction",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,173 +1,55 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 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,
|
Key Features:
|
||||||
providing a structured way to compose, execute, recover, and manage sequences of
|
- Lifecycle hooks: `before`, `on_success`, `on_error`, `after`, `on_teardown`
|
||||||
operations.
|
- 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:
|
Usage Scenarios:
|
||||||
result = action(*args, **kwargs)
|
- 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:
|
Example:
|
||||||
- Full hook lifecycle support (before, on_success, on_error, after, on_teardown).
|
def compute(x, y):
|
||||||
- Consistent timing and execution context tracking for each run.
|
return x + y
|
||||||
- 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(
|
||||||
- Action: wraps a function or coroutine into a standard executable unit.
|
name="AddNumbers",
|
||||||
- ChainedAction: runs actions sequentially, optionally injecting last results.
|
action=compute,
|
||||||
- ActionGroup: runs actions in parallel and gathers results.
|
args=(2, 3),
|
||||||
- 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.
|
This module serves as the foundation for building robust, observable,
|
||||||
|
and composable CLI automation flows in Falyx.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
from typing import Any, Awaitable, Callable
|
||||||
import random
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from concurrent.futures import ProcessPoolExecutor
|
|
||||||
from functools import cached_property, partial
|
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
from rich.console import Console
|
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
from falyx.context import ExecutionContext, SharedContext
|
from falyx.action.base_action import BaseAction
|
||||||
from falyx.debug import register_debug_hooks
|
from falyx.context import ExecutionContext
|
||||||
from falyx.exceptions import EmptyChainError
|
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
from falyx.hook_manager import Hook, HookManager, HookType
|
from falyx.hook_manager import HookManager, HookType
|
||||||
from falyx.logger import logger
|
from falyx.logger import logger
|
||||||
from falyx.options_manager import OptionsManager
|
|
||||||
from falyx.parsers.utils import same_argument_definitions
|
|
||||||
from falyx.retry import RetryHandler, RetryPolicy
|
from falyx.retry import RetryHandler, RetryPolicy
|
||||||
from falyx.themes import OneColors
|
from falyx.themes import OneColors
|
||||||
from falyx.utils import ensure_async
|
from falyx.utils import ensure_async
|
||||||
|
|
||||||
|
|
||||||
class BaseAction(ABC):
|
|
||||||
"""
|
|
||||||
Base class for actions. Actions can be simple functions or more
|
|
||||||
complex actions like `ChainedAction` or `ActionGroup`. They can also
|
|
||||||
be run independently or as part of Falyx.
|
|
||||||
|
|
||||||
inject_last_result (bool): Whether to inject the previous action's result
|
|
||||||
into kwargs.
|
|
||||||
inject_into (str): The name of the kwarg key to inject the result as
|
|
||||||
(default: 'last_result').
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
*,
|
|
||||||
hooks: HookManager | None = None,
|
|
||||||
inject_last_result: bool = False,
|
|
||||||
inject_into: str = "last_result",
|
|
||||||
never_prompt: bool = False,
|
|
||||||
logging_hooks: bool = False,
|
|
||||||
) -> None:
|
|
||||||
self.name = name
|
|
||||||
self.hooks = hooks or HookManager()
|
|
||||||
self.is_retryable: bool = False
|
|
||||||
self.shared_context: SharedContext | None = None
|
|
||||||
self.inject_last_result: bool = inject_last_result
|
|
||||||
self.inject_into: str = inject_into
|
|
||||||
self._never_prompt: bool = never_prompt
|
|
||||||
self._skip_in_chain: bool = False
|
|
||||||
self.console = Console(color_system="auto")
|
|
||||||
self.options_manager: OptionsManager | None = None
|
|
||||||
|
|
||||||
if logging_hooks:
|
|
||||||
register_debug_hooks(self.hooks)
|
|
||||||
|
|
||||||
async def __call__(self, *args, **kwargs) -> Any:
|
|
||||||
return await self._run(*args, **kwargs)
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def _run(self, *args, **kwargs) -> Any:
|
|
||||||
raise NotImplementedError("_run must be implemented by subclasses")
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def preview(self, parent: Tree | None = None):
|
|
||||||
raise NotImplementedError("preview must be implemented by subclasses")
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
|
|
||||||
"""
|
|
||||||
Returns the callable to be used for argument inference.
|
|
||||||
By default, it returns None.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError("get_infer_target must be implemented by subclasses")
|
|
||||||
|
|
||||||
def set_options_manager(self, options_manager: OptionsManager) -> None:
|
|
||||||
self.options_manager = options_manager
|
|
||||||
|
|
||||||
def set_shared_context(self, shared_context: SharedContext) -> None:
|
|
||||||
self.shared_context = shared_context
|
|
||||||
|
|
||||||
def get_option(self, option_name: str, default: Any = None) -> Any:
|
|
||||||
"""
|
|
||||||
Resolve an option from the OptionsManager if present, otherwise use the fallback.
|
|
||||||
"""
|
|
||||||
if self.options_manager:
|
|
||||||
return self.options_manager.get(option_name, default)
|
|
||||||
return default
|
|
||||||
|
|
||||||
@property
|
|
||||||
def last_result(self) -> Any:
|
|
||||||
"""Return the last result from the shared context."""
|
|
||||||
if self.shared_context:
|
|
||||||
return self.shared_context.last_result()
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def never_prompt(self) -> bool:
|
|
||||||
return self.get_option("never_prompt", self._never_prompt)
|
|
||||||
|
|
||||||
def prepare(
|
|
||||||
self, shared_context: SharedContext, options_manager: OptionsManager | None = None
|
|
||||||
) -> BaseAction:
|
|
||||||
"""
|
|
||||||
Prepare the action specifically for sequential (ChainedAction) execution.
|
|
||||||
Can be overridden for chain-specific logic.
|
|
||||||
"""
|
|
||||||
self.set_shared_context(shared_context)
|
|
||||||
if options_manager:
|
|
||||||
self.set_options_manager(options_manager)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def _maybe_inject_last_result(self, kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
if self.inject_last_result and self.shared_context:
|
|
||||||
key = self.inject_into
|
|
||||||
if key in kwargs:
|
|
||||||
logger.warning("[%s] ⚠️ Overriding '%s' with last_result", self.name, key)
|
|
||||||
kwargs = dict(kwargs)
|
|
||||||
kwargs[key] = self.shared_context.last_result()
|
|
||||||
return kwargs
|
|
||||||
|
|
||||||
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
|
|
||||||
"""Register a hook for all actions and sub-actions."""
|
|
||||||
self.hooks.register(hook_type, hook)
|
|
||||||
|
|
||||||
async def _write_stdout(self, data: str) -> None:
|
|
||||||
"""Override in subclasses that produce terminal output."""
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return str(self)
|
|
||||||
|
|
||||||
|
|
||||||
class Action(BaseAction):
|
class Action(BaseAction):
|
||||||
"""
|
"""Action wraps a simple function or coroutine into a standard executable unit.
|
||||||
Action wraps a simple function or coroutine into a standard executable unit.
|
|
||||||
|
|
||||||
It supports:
|
It supports:
|
||||||
- Optional retry logic.
|
- Optional retry logic.
|
||||||
@@ -176,11 +58,11 @@ class Action(BaseAction):
|
|||||||
- Optional rollback handlers for undo logic.
|
- Optional rollback handlers for undo logic.
|
||||||
|
|
||||||
Args:
|
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.
|
action (Callable): The function or coroutine to execute.
|
||||||
rollback (Callable, optional): Rollback function to undo the action.
|
rollback (Callable, optional): Rollback function to undo the action.
|
||||||
args (tuple, optional): Static positional arguments.
|
args (tuple, optional): Positional arguments.
|
||||||
kwargs (dict, optional): Static keyword arguments.
|
kwargs (dict, optional): Keyword arguments.
|
||||||
hooks (HookManager, optional): Hook manager for lifecycle events.
|
hooks (HookManager, optional): Hook manager for lifecycle events.
|
||||||
inject_last_result (bool, optional): Enable last_result injection.
|
inject_last_result (bool, optional): Enable last_result injection.
|
||||||
inject_into (str, optional): Name of injected key.
|
inject_into (str, optional): Name of injected key.
|
||||||
@@ -191,22 +73,36 @@ class Action(BaseAction):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str,
|
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, ...] = (),
|
args: tuple[Any, ...] = (),
|
||||||
kwargs: dict[str, Any] | None = None,
|
kwargs: dict[str, Any] | None = None,
|
||||||
hooks: HookManager | None = None,
|
hooks: HookManager | None = None,
|
||||||
inject_last_result: bool = False,
|
inject_last_result: bool = False,
|
||||||
inject_into: str = "last_result",
|
inject_into: str = "last_result",
|
||||||
|
never_prompt: bool | None = None,
|
||||||
|
logging_hooks: bool = False,
|
||||||
retry: bool = False,
|
retry: bool = False,
|
||||||
retry_policy: RetryPolicy | None = None,
|
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:
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
name,
|
name,
|
||||||
hooks=hooks,
|
hooks=hooks,
|
||||||
inject_last_result=inject_last_result,
|
inject_last_result=inject_last_result,
|
||||||
inject_into=inject_into,
|
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.action = action
|
||||||
self.rollback = rollback
|
self.rollback = rollback
|
||||||
@@ -218,19 +114,19 @@ class Action(BaseAction):
|
|||||||
self.enable_retry()
|
self.enable_retry()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def action(self) -> Callable[..., Any]:
|
def action(self) -> Callable[..., Awaitable[Any]]:
|
||||||
return self._action
|
return self._action
|
||||||
|
|
||||||
@action.setter
|
@action.setter
|
||||||
def action(self, value: Callable[..., Any]):
|
def action(self, value: Callable[..., Awaitable[Any]]):
|
||||||
self._action = ensure_async(value)
|
self._action = ensure_async(value)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def rollback(self) -> Callable[..., Any] | None:
|
def rollback(self) -> Callable[..., Awaitable[Any]] | None:
|
||||||
return self._rollback
|
return self._rollback
|
||||||
|
|
||||||
@rollback.setter
|
@rollback.setter
|
||||||
def rollback(self, value: Callable[..., Any] | None):
|
def rollback(self, value: Callable[..., Awaitable[Any]] | None):
|
||||||
if value is None:
|
if value is None:
|
||||||
self._rollback = None
|
self._rollback = None
|
||||||
else:
|
else:
|
||||||
@@ -250,8 +146,8 @@ class Action(BaseAction):
|
|||||||
self.enable_retry()
|
self.enable_retry()
|
||||||
|
|
||||||
def get_infer_target(self) -> tuple[Callable[..., Any], None]:
|
def get_infer_target(self) -> tuple[Callable[..., Any], None]:
|
||||||
"""
|
"""Returns the callable to be used for argument inference.
|
||||||
Returns the callable to be used for argument inference.
|
|
||||||
By default, it returns the action itself.
|
By default, it returns the action itself.
|
||||||
"""
|
"""
|
||||||
return self.action, None
|
return self.action, None
|
||||||
@@ -278,7 +174,7 @@ class Action(BaseAction):
|
|||||||
context.exception = error
|
context.exception = error
|
||||||
await self.hooks.trigger(HookType.ON_ERROR, context)
|
await self.hooks.trigger(HookType.ON_ERROR, context)
|
||||||
if context.result is not None:
|
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
|
return context.result
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
@@ -307,576 +203,6 @@ class Action(BaseAction):
|
|||||||
f"Action(name={self.name!r}, action="
|
f"Action(name={self.name!r}, action="
|
||||||
f"{getattr(self._action, '__name__', repr(self._action))}, "
|
f"{getattr(self._action, '__name__', repr(self._action))}, "
|
||||||
f"args={self.args!r}, kwargs={self.kwargs!r}, "
|
f"args={self.args!r}, kwargs={self.kwargs!r}, "
|
||||||
f"retry={self.retry_policy.enabled})"
|
f"retry={self.retry_policy.enabled}, "
|
||||||
)
|
f"rollback={self.rollback is not None})"
|
||||||
|
|
||||||
|
|
||||||
class LiteralInputAction(Action):
|
|
||||||
"""
|
|
||||||
LiteralInputAction injects a static value into a ChainedAction.
|
|
||||||
|
|
||||||
This allows embedding hardcoded values mid-pipeline, useful when:
|
|
||||||
- Providing default or fallback inputs.
|
|
||||||
- Starting a pipeline with a fixed input.
|
|
||||||
- Supplying missing context manually.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
value (Any): The static value to inject.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, value: Any):
|
|
||||||
self._value = value
|
|
||||||
|
|
||||||
async def literal(*_, **__):
|
|
||||||
return value
|
|
||||||
|
|
||||||
super().__init__("Input", literal)
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def value(self) -> Any:
|
|
||||||
"""Return the literal value."""
|
|
||||||
return self._value
|
|
||||||
|
|
||||||
async def preview(self, parent: Tree | None = None):
|
|
||||||
label = [f"[{OneColors.LIGHT_YELLOW}]📥 LiteralInput[/] '{self.name}'"]
|
|
||||||
label.append(f" [dim](value = {repr(self.value)})[/dim]")
|
|
||||||
if parent:
|
|
||||||
parent.add("".join(label))
|
|
||||||
else:
|
|
||||||
self.console.print(Tree("".join(label)))
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"LiteralInputAction(value={self.value!r})"
|
|
||||||
|
|
||||||
|
|
||||||
class FallbackAction(Action):
|
|
||||||
"""
|
|
||||||
FallbackAction provides a default value if the previous action failed or
|
|
||||||
returned None.
|
|
||||||
|
|
||||||
It injects the last result and checks:
|
|
||||||
- If last_result is not None, it passes it through unchanged.
|
|
||||||
- If last_result is None (e.g., due to failure), it replaces it with a fallback value.
|
|
||||||
|
|
||||||
Used in ChainedAction pipelines to gracefully recover from errors or missing data.
|
|
||||||
When activated, it consumes the preceding error and allows the chain to continue
|
|
||||||
normally.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
fallback (Any): The fallback value to use if last_result is None.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, fallback: Any):
|
|
||||||
self._fallback = fallback
|
|
||||||
|
|
||||||
async def _fallback_logic(last_result):
|
|
||||||
return last_result if last_result is not None else fallback
|
|
||||||
|
|
||||||
super().__init__(name="Fallback", action=_fallback_logic, inject_last_result=True)
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def fallback(self) -> Any:
|
|
||||||
"""Return the fallback value."""
|
|
||||||
return self._fallback
|
|
||||||
|
|
||||||
async def preview(self, parent: Tree | None = None):
|
|
||||||
label = [f"[{OneColors.LIGHT_RED}]🛟 Fallback[/] '{self.name}'"]
|
|
||||||
label.append(f" [dim](uses fallback = {repr(self.fallback)})[/dim]")
|
|
||||||
if parent:
|
|
||||||
parent.add("".join(label))
|
|
||||||
else:
|
|
||||||
self.console.print(Tree("".join(label)))
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"FallbackAction(fallback={self.fallback!r})"
|
|
||||||
|
|
||||||
|
|
||||||
class ActionListMixin:
|
|
||||||
"""Mixin for managing a list of actions."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.actions: list[BaseAction] = []
|
|
||||||
|
|
||||||
def set_actions(self, actions: list[BaseAction]) -> None:
|
|
||||||
"""Replaces the current action list with a new one."""
|
|
||||||
self.actions.clear()
|
|
||||||
for action in actions:
|
|
||||||
self.add_action(action)
|
|
||||||
|
|
||||||
def add_action(self, action: BaseAction) -> None:
|
|
||||||
"""Adds an action to the list."""
|
|
||||||
self.actions.append(action)
|
|
||||||
|
|
||||||
def remove_action(self, name: str) -> None:
|
|
||||||
"""Removes an action by name."""
|
|
||||||
self.actions = [action for action in self.actions if action.name != name]
|
|
||||||
|
|
||||||
def has_action(self, name: str) -> bool:
|
|
||||||
"""Checks if an action with the given name exists."""
|
|
||||||
return any(action.name == name for action in self.actions)
|
|
||||||
|
|
||||||
def get_action(self, name: str) -> BaseAction | None:
|
|
||||||
"""Retrieves an action by name."""
|
|
||||||
for action in self.actions:
|
|
||||||
if action.name == name:
|
|
||||||
return action
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class ChainedAction(BaseAction, ActionListMixin):
|
|
||||||
"""
|
|
||||||
ChainedAction executes a sequence of actions one after another.
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Supports optional automatic last_result injection (auto_inject).
|
|
||||||
- Recovers from intermediate errors using FallbackAction if present.
|
|
||||||
- Rolls back all previously executed actions if a failure occurs.
|
|
||||||
- Handles literal values with LiteralInputAction.
|
|
||||||
|
|
||||||
Best used for defining robust, ordered workflows where each step can depend on
|
|
||||||
previous results.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name (str): Name of the chain.
|
|
||||||
actions (list): List of actions or literals to execute.
|
|
||||||
hooks (HookManager, optional): Hooks for lifecycle events.
|
|
||||||
inject_last_result (bool, optional): Whether to inject last results into kwargs
|
|
||||||
by default.
|
|
||||||
inject_into (str, optional): Key name for injection.
|
|
||||||
auto_inject (bool, optional): Auto-enable injection for subsequent actions.
|
|
||||||
return_list (bool, optional): Whether to return a list of all results. False
|
|
||||||
returns the last result.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
actions: list[BaseAction | Any] | None = None,
|
|
||||||
*,
|
|
||||||
hooks: HookManager | None = None,
|
|
||||||
inject_last_result: bool = False,
|
|
||||||
inject_into: str = "last_result",
|
|
||||||
auto_inject: bool = False,
|
|
||||||
return_list: bool = False,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(
|
|
||||||
name,
|
|
||||||
hooks=hooks,
|
|
||||||
inject_last_result=inject_last_result,
|
|
||||||
inject_into=inject_into,
|
|
||||||
)
|
|
||||||
ActionListMixin.__init__(self)
|
|
||||||
self.auto_inject = auto_inject
|
|
||||||
self.return_list = return_list
|
|
||||||
if actions:
|
|
||||||
self.set_actions(actions)
|
|
||||||
|
|
||||||
def _wrap_if_needed(self, action: BaseAction | Any) -> BaseAction:
|
|
||||||
if isinstance(action, BaseAction):
|
|
||||||
return action
|
|
||||||
elif callable(action):
|
|
||||||
return Action(name=action.__name__, action=action)
|
|
||||||
else:
|
|
||||||
return LiteralInputAction(action)
|
|
||||||
|
|
||||||
def add_action(self, action: BaseAction | Any) -> None:
|
|
||||||
action = self._wrap_if_needed(action)
|
|
||||||
if self.actions and self.auto_inject and not action.inject_last_result:
|
|
||||||
action.inject_last_result = True
|
|
||||||
super().add_action(action)
|
|
||||||
if hasattr(action, "register_teardown") and callable(action.register_teardown):
|
|
||||||
action.register_teardown(self.hooks)
|
|
||||||
|
|
||||||
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
|
|
||||||
if self.actions:
|
|
||||||
return self.actions[0].get_infer_target()
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
def _clear_args(self):
|
|
||||||
return (), {}
|
|
||||||
|
|
||||||
async def _run(self, *args, **kwargs) -> list[Any]:
|
|
||||||
if not self.actions:
|
|
||||||
raise EmptyChainError(f"[{self.name}] No actions to execute.")
|
|
||||||
|
|
||||||
shared_context = SharedContext(name=self.name, action=self)
|
|
||||||
if self.shared_context:
|
|
||||||
shared_context.add_result(self.shared_context.last_result())
|
|
||||||
updated_kwargs = self._maybe_inject_last_result(kwargs)
|
|
||||||
context = ExecutionContext(
|
|
||||||
name=self.name,
|
|
||||||
args=args,
|
|
||||||
kwargs=updated_kwargs,
|
|
||||||
action=self,
|
|
||||||
extra={"results": [], "rollback_stack": []},
|
|
||||||
shared_context=shared_context,
|
|
||||||
)
|
|
||||||
context.start_timer()
|
|
||||||
try:
|
|
||||||
await self.hooks.trigger(HookType.BEFORE, context)
|
|
||||||
|
|
||||||
for index, action in enumerate(self.actions):
|
|
||||||
if action._skip_in_chain:
|
|
||||||
logger.debug(
|
|
||||||
"[%s] ⚠️ Skipping consumed action '%s'", self.name, action.name
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
shared_context.current_index = index
|
|
||||||
prepared = action.prepare(shared_context, self.options_manager)
|
|
||||||
try:
|
|
||||||
result = await prepared(*args, **updated_kwargs)
|
|
||||||
except Exception as error:
|
|
||||||
if index + 1 < len(self.actions) and isinstance(
|
|
||||||
self.actions[index + 1], FallbackAction
|
|
||||||
):
|
|
||||||
logger.warning(
|
|
||||||
"[%s] ⚠️ Fallback triggered: %s, recovering with fallback "
|
|
||||||
"'%s'.",
|
|
||||||
self.name,
|
|
||||||
error,
|
|
||||||
self.actions[index + 1].name,
|
|
||||||
)
|
|
||||||
shared_context.add_result(None)
|
|
||||||
context.extra["results"].append(None)
|
|
||||||
fallback = self.actions[index + 1].prepare(shared_context)
|
|
||||||
result = await fallback()
|
|
||||||
fallback._skip_in_chain = True
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
args, updated_kwargs = self._clear_args()
|
|
||||||
shared_context.add_result(result)
|
|
||||||
context.extra["results"].append(result)
|
|
||||||
context.extra["rollback_stack"].append(prepared)
|
|
||||||
|
|
||||||
all_results = context.extra["results"]
|
|
||||||
assert (
|
|
||||||
all_results
|
|
||||||
), f"[{self.name}] No results captured. Something seriously went wrong."
|
|
||||||
context.result = all_results if self.return_list else all_results[-1]
|
|
||||||
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
|
||||||
return context.result
|
|
||||||
|
|
||||||
except Exception as error:
|
|
||||||
context.exception = error
|
|
||||||
shared_context.add_error(shared_context.current_index, error)
|
|
||||||
await self._rollback(context.extra["rollback_stack"], *args, **kwargs)
|
|
||||||
await self.hooks.trigger(HookType.ON_ERROR, context)
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
context.stop_timer()
|
|
||||||
await self.hooks.trigger(HookType.AFTER, context)
|
|
||||||
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
|
||||||
er.record(context)
|
|
||||||
|
|
||||||
async def _rollback(self, rollback_stack, *args, **kwargs):
|
|
||||||
"""
|
|
||||||
Roll back all executed actions in reverse order.
|
|
||||||
|
|
||||||
Rollbacks run even if a fallback recovered from failure,
|
|
||||||
ensuring consistent undo of all side effects.
|
|
||||||
|
|
||||||
Actions without rollback handlers are skipped.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
rollback_stack (list): Actions to roll back.
|
|
||||||
*args, **kwargs: Passed to rollback handlers.
|
|
||||||
"""
|
|
||||||
for action in reversed(rollback_stack):
|
|
||||||
rollback = getattr(action, "rollback", None)
|
|
||||||
if rollback:
|
|
||||||
try:
|
|
||||||
logger.warning("[%s] ↩️ Rolling back...", action.name)
|
|
||||||
await action.rollback(*args, **kwargs)
|
|
||||||
except Exception as error:
|
|
||||||
logger.error("[%s] ⚠️ Rollback failed: %s", action.name, error)
|
|
||||||
|
|
||||||
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
|
|
||||||
"""Register a hook for all actions and sub-actions."""
|
|
||||||
self.hooks.register(hook_type, hook)
|
|
||||||
for action in self.actions:
|
|
||||||
action.register_hooks_recursively(hook_type, hook)
|
|
||||||
|
|
||||||
async def preview(self, parent: Tree | None = None):
|
|
||||||
label = [f"[{OneColors.CYAN_b}]⛓ ChainedAction[/] '{self.name}'"]
|
|
||||||
if self.inject_last_result:
|
|
||||||
label.append(f" [dim](injects '{self.inject_into}')[/dim]")
|
|
||||||
tree = parent.add("".join(label)) if parent else Tree("".join(label))
|
|
||||||
for action in self.actions:
|
|
||||||
await action.preview(parent=tree)
|
|
||||||
if not parent:
|
|
||||||
self.console.print(tree)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return (
|
|
||||||
f"ChainedAction(name={self.name!r}, "
|
|
||||||
f"actions={[a.name for a in self.actions]!r}, "
|
|
||||||
f"auto_inject={self.auto_inject}, return_list={self.return_list})"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ActionGroup(BaseAction, ActionListMixin):
|
|
||||||
"""
|
|
||||||
ActionGroup executes multiple actions concurrently in parallel.
|
|
||||||
|
|
||||||
It is ideal for independent tasks that can be safely run simultaneously,
|
|
||||||
improving overall throughput and responsiveness of workflows.
|
|
||||||
|
|
||||||
Core features:
|
|
||||||
- Parallel execution of all contained actions.
|
|
||||||
- Shared last_result injection across all actions if configured.
|
|
||||||
- Aggregated collection of individual results as (name, result) pairs.
|
|
||||||
- Hook lifecycle support (before, on_success, on_error, after, on_teardown).
|
|
||||||
- Error aggregation: captures all action errors and reports them together.
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
- If any action fails, the group collects the errors but continues executing
|
|
||||||
other actions without interruption.
|
|
||||||
- After all actions complete, ActionGroup raises a single exception summarizing
|
|
||||||
all failures, or returns all results if successful.
|
|
||||||
|
|
||||||
Best used for:
|
|
||||||
- Batch processing multiple independent tasks.
|
|
||||||
- Reducing latency for workflows with parallelizable steps.
|
|
||||||
- Isolating errors while maximizing successful execution.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name (str): Name of the chain.
|
|
||||||
actions (list): List of actions or literals to execute.
|
|
||||||
hooks (HookManager, optional): Hooks for lifecycle events.
|
|
||||||
inject_last_result (bool, optional): Whether to inject last results into kwargs
|
|
||||||
by default.
|
|
||||||
inject_into (str, optional): Key name for injection.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
actions: list[BaseAction] | None = None,
|
|
||||||
*,
|
|
||||||
hooks: HookManager | None = None,
|
|
||||||
inject_last_result: bool = False,
|
|
||||||
inject_into: str = "last_result",
|
|
||||||
):
|
|
||||||
super().__init__(
|
|
||||||
name,
|
|
||||||
hooks=hooks,
|
|
||||||
inject_last_result=inject_last_result,
|
|
||||||
inject_into=inject_into,
|
|
||||||
)
|
|
||||||
ActionListMixin.__init__(self)
|
|
||||||
if actions:
|
|
||||||
self.set_actions(actions)
|
|
||||||
|
|
||||||
def _wrap_if_needed(self, action: BaseAction | Any) -> BaseAction:
|
|
||||||
if isinstance(action, BaseAction):
|
|
||||||
return action
|
|
||||||
elif callable(action):
|
|
||||||
return Action(name=action.__name__, action=action)
|
|
||||||
else:
|
|
||||||
raise TypeError(
|
|
||||||
"ActionGroup only accepts BaseAction or callable, got "
|
|
||||||
f"{type(action).__name__}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def add_action(self, action: BaseAction | Any) -> None:
|
|
||||||
action = self._wrap_if_needed(action)
|
|
||||||
super().add_action(action)
|
|
||||||
if hasattr(action, "register_teardown") and callable(action.register_teardown):
|
|
||||||
action.register_teardown(self.hooks)
|
|
||||||
|
|
||||||
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
|
|
||||||
arg_defs = same_argument_definitions(self.actions)
|
|
||||||
if arg_defs:
|
|
||||||
return self.actions[0].get_infer_target()
|
|
||||||
logger.debug(
|
|
||||||
"[%s] auto_args disabled: mismatched ActionGroup arguments",
|
|
||||||
self.name,
|
|
||||||
)
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]:
|
|
||||||
shared_context = SharedContext(name=self.name, action=self, is_parallel=True)
|
|
||||||
if self.shared_context:
|
|
||||||
shared_context.set_shared_result(self.shared_context.last_result())
|
|
||||||
updated_kwargs = self._maybe_inject_last_result(kwargs)
|
|
||||||
context = ExecutionContext(
|
|
||||||
name=self.name,
|
|
||||||
args=args,
|
|
||||||
kwargs=updated_kwargs,
|
|
||||||
action=self,
|
|
||||||
extra={"results": [], "errors": []},
|
|
||||||
shared_context=shared_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def run_one(action: BaseAction):
|
|
||||||
try:
|
|
||||||
prepared = action.prepare(shared_context, self.options_manager)
|
|
||||||
result = await prepared(*args, **updated_kwargs)
|
|
||||||
shared_context.add_result((action.name, result))
|
|
||||||
context.extra["results"].append((action.name, result))
|
|
||||||
except Exception as error:
|
|
||||||
shared_context.add_error(shared_context.current_index, error)
|
|
||||||
context.extra["errors"].append((action.name, error))
|
|
||||||
|
|
||||||
context.start_timer()
|
|
||||||
try:
|
|
||||||
await self.hooks.trigger(HookType.BEFORE, context)
|
|
||||||
await asyncio.gather(*[run_one(a) for a in self.actions])
|
|
||||||
|
|
||||||
if context.extra["errors"]:
|
|
||||||
context.exception = Exception(
|
|
||||||
f"{len(context.extra['errors'])} action(s) failed: "
|
|
||||||
f"{' ,'.join(name for name, _ in context.extra["errors"])}"
|
|
||||||
)
|
|
||||||
await self.hooks.trigger(HookType.ON_ERROR, context)
|
|
||||||
raise context.exception
|
|
||||||
|
|
||||||
context.result = context.extra["results"]
|
|
||||||
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
|
||||||
return context.result
|
|
||||||
|
|
||||||
except Exception as error:
|
|
||||||
context.exception = error
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
context.stop_timer()
|
|
||||||
await self.hooks.trigger(HookType.AFTER, context)
|
|
||||||
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
|
||||||
er.record(context)
|
|
||||||
|
|
||||||
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
|
|
||||||
"""Register a hook for all actions and sub-actions."""
|
|
||||||
super().register_hooks_recursively(hook_type, hook)
|
|
||||||
for action in self.actions:
|
|
||||||
action.register_hooks_recursively(hook_type, hook)
|
|
||||||
|
|
||||||
async def preview(self, parent: Tree | None = None):
|
|
||||||
label = [f"[{OneColors.MAGENTA_b}]⏩ ActionGroup (parallel)[/] '{self.name}'"]
|
|
||||||
if self.inject_last_result:
|
|
||||||
label.append(f" [dim](receives '{self.inject_into}')[/dim]")
|
|
||||||
tree = parent.add("".join(label)) if parent else Tree("".join(label))
|
|
||||||
actions = self.actions.copy()
|
|
||||||
random.shuffle(actions)
|
|
||||||
await asyncio.gather(*(action.preview(parent=tree) for action in actions))
|
|
||||||
if not parent:
|
|
||||||
self.console.print(tree)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return (
|
|
||||||
f"ActionGroup(name={self.name!r}, actions={[a.name for a in self.actions]!r},"
|
|
||||||
f" inject_last_result={self.inject_last_result})"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ProcessAction(BaseAction):
|
|
||||||
"""
|
|
||||||
ProcessAction runs a function in a separate process using ProcessPoolExecutor.
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Executes CPU-bound or blocking tasks without blocking the main event loop.
|
|
||||||
- Supports last_result injection into the subprocess.
|
|
||||||
- Validates that last_result is pickleable when injection is enabled.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name (str): Name of the action.
|
|
||||||
func (Callable): Function to execute in a new process.
|
|
||||||
args (tuple, optional): Positional arguments.
|
|
||||||
kwargs (dict, optional): Keyword arguments.
|
|
||||||
hooks (HookManager, optional): Hook manager for lifecycle events.
|
|
||||||
executor (ProcessPoolExecutor, optional): Custom executor if desired.
|
|
||||||
inject_last_result (bool, optional): Inject last result into the function.
|
|
||||||
inject_into (str, optional): Name of the injected key.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
action: Callable[..., Any],
|
|
||||||
*,
|
|
||||||
args: tuple = (),
|
|
||||||
kwargs: dict[str, Any] | None = None,
|
|
||||||
hooks: HookManager | None = None,
|
|
||||||
executor: ProcessPoolExecutor | None = None,
|
|
||||||
inject_last_result: bool = False,
|
|
||||||
inject_into: str = "last_result",
|
|
||||||
):
|
|
||||||
super().__init__(
|
|
||||||
name,
|
|
||||||
hooks=hooks,
|
|
||||||
inject_last_result=inject_last_result,
|
|
||||||
inject_into=inject_into,
|
|
||||||
)
|
|
||||||
self.action = action
|
|
||||||
self.args = args
|
|
||||||
self.kwargs = kwargs or {}
|
|
||||||
self.executor = executor or ProcessPoolExecutor()
|
|
||||||
self.is_retryable = True
|
|
||||||
|
|
||||||
def get_infer_target(self) -> tuple[Callable[..., Any] | None, None]:
|
|
||||||
return self.action, None
|
|
||||||
|
|
||||||
async def _run(self, *args, **kwargs) -> Any:
|
|
||||||
if self.inject_last_result and self.shared_context:
|
|
||||||
last_result = self.shared_context.last_result()
|
|
||||||
if not self._validate_pickleable(last_result):
|
|
||||||
raise ValueError(
|
|
||||||
f"Cannot inject last result into {self.name}: "
|
|
||||||
f"last result is not pickleable."
|
|
||||||
)
|
|
||||||
combined_args = args + self.args
|
|
||||||
combined_kwargs = self._maybe_inject_last_result({**self.kwargs, **kwargs})
|
|
||||||
context = ExecutionContext(
|
|
||||||
name=self.name,
|
|
||||||
args=combined_args,
|
|
||||||
kwargs=combined_kwargs,
|
|
||||||
action=self,
|
|
||||||
)
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
|
|
||||||
context.start_timer()
|
|
||||||
try:
|
|
||||||
await self.hooks.trigger(HookType.BEFORE, context)
|
|
||||||
result = await loop.run_in_executor(
|
|
||||||
self.executor, partial(self.action, *combined_args, **combined_kwargs)
|
|
||||||
)
|
|
||||||
context.result = result
|
|
||||||
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
|
||||||
return result
|
|
||||||
except Exception as error:
|
|
||||||
context.exception = error
|
|
||||||
await self.hooks.trigger(HookType.ON_ERROR, context)
|
|
||||||
if context.result is not None:
|
|
||||||
return context.result
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
context.stop_timer()
|
|
||||||
await self.hooks.trigger(HookType.AFTER, context)
|
|
||||||
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
|
||||||
er.record(context)
|
|
||||||
|
|
||||||
def _validate_pickleable(self, obj: Any) -> bool:
|
|
||||||
try:
|
|
||||||
import pickle
|
|
||||||
|
|
||||||
pickle.dumps(obj)
|
|
||||||
return True
|
|
||||||
except (pickle.PicklingError, TypeError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def preview(self, parent: Tree | None = None):
|
|
||||||
label = [
|
|
||||||
f"[{OneColors.DARK_YELLOW_b}]🧠 ProcessAction (new process)[/] '{self.name}'"
|
|
||||||
]
|
|
||||||
if self.inject_last_result:
|
|
||||||
label.append(f" [dim](injects '{self.inject_into}')[/dim]")
|
|
||||||
if parent:
|
|
||||||
parent.add("".join(label))
|
|
||||||
else:
|
|
||||||
self.console.print(Tree("".join(label)))
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return (
|
|
||||||
f"ProcessAction(name={self.name!r}, "
|
|
||||||
f"action={getattr(self.action, '__name__', repr(self.action))}, "
|
|
||||||
f"args={self.args!r}, kwargs={self.kwargs!r})"
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,40 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""action_factory.py"""
|
"""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 typing import Any, Callable
|
||||||
|
|
||||||
from rich.tree import Tree
|
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.context import ExecutionContext
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
from falyx.hook_manager import HookType
|
from falyx.hook_manager import HookType
|
||||||
@@ -14,18 +44,21 @@ from falyx.themes import OneColors
|
|||||||
from falyx.utils import ensure_async
|
from falyx.utils import ensure_async
|
||||||
|
|
||||||
|
|
||||||
class ActionFactoryAction(BaseAction):
|
class ActionFactory(BaseAction):
|
||||||
"""
|
"""Dynamically creates and runs another Action at runtime using a factory function.
|
||||||
Dynamically creates and runs another Action at runtime using a factory function.
|
|
||||||
|
|
||||||
This is useful for generating context-specific behavior (e.g., dynamic HTTPActions)
|
This is useful for generating context-specific behavior (e.g., dynamic HTTPActions)
|
||||||
where the structure of the next action depends on runtime values.
|
where the structure of the next action depends on runtime values.
|
||||||
|
|
||||||
Args:
|
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.
|
factory (Callable): A function that returns a BaseAction given args/kwargs.
|
||||||
inject_last_result (bool): Whether to inject last_result into the factory.
|
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.
|
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__(
|
def __init__(
|
||||||
@@ -112,7 +145,16 @@ class ActionFactoryAction(BaseAction):
|
|||||||
tree = parent.add(label) if parent else Tree(label)
|
tree = parent.add(label) if parent else Tree(label)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
generated = await self.factory(*self.preview_args, **self.preview_kwargs)
|
generated = None
|
||||||
|
if self.args or self.kwargs:
|
||||||
|
try:
|
||||||
|
generated = await self.factory(*self.args, **self.kwargs)
|
||||||
|
except TypeError:
|
||||||
|
...
|
||||||
|
|
||||||
|
if not generated:
|
||||||
|
generated = await self.factory(*self.preview_args, **self.preview_kwargs)
|
||||||
|
|
||||||
if isinstance(generated, BaseAction):
|
if isinstance(generated, BaseAction):
|
||||||
await generated.preview(parent=tree)
|
await generated.preview(parent=tree)
|
||||||
else:
|
else:
|
||||||
@@ -124,3 +166,11 @@ class ActionFactoryAction(BaseAction):
|
|||||||
|
|
||||||
if not parent:
|
if not parent:
|
||||||
self.console.print(tree)
|
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})"
|
||||||
|
)
|
||||||
|
|||||||
246
falyx/action/action_group.py
Normal file
246
falyx/action/action_group.py
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
|
"""Defines `ActionGroup`, a Falyx Action that executes multiple sub-actions concurrently
|
||||||
|
using asynchronous concurrency.
|
||||||
|
|
||||||
|
`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 concurrently 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="ConcurrentChecks",
|
||||||
|
actions=[Action(...), Action(...), ChainedAction(...)],
|
||||||
|
)
|
||||||
|
|
||||||
|
This module complements `ChainedAction` by offering breadth-wise (concurrent) 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.
|
||||||
|
|
||||||
|
It is ideal for independent tasks that can be safely run simultaneously,
|
||||||
|
improving overall throughput and responsiveness of workflows.
|
||||||
|
|
||||||
|
Core features:
|
||||||
|
- Concurrent 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 concurrent 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_concurrent=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 (concurrent)[/] '{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})"
|
||||||
|
)
|
||||||
59
falyx/action/action_mixins.py
Normal file
59
falyx/action/action_mixins.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 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 Any, 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
|
||||||
201
falyx/action/action_types.py
Normal file
201
falyx/action/action_types.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 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
|
||||||
183
falyx/action/base_action.py
Normal file
183
falyx/action/base_action.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 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, concurrent 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 concurrently 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, else default."""
|
||||||
|
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)
|
||||||
320
falyx/action/chained_action.py
Normal file
320
falyx/action/chained_action.py
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 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]]]
|
||||||
|
| 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})"
|
||||||
|
)
|
||||||
269
falyx/action/confirm_action.py
Normal file
269
falyx/action/confirm_action.py
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 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})"
|
||||||
|
)
|
||||||
85
falyx/action/fallback_action.py
Normal file
85
falyx/action/fallback_action.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 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})"
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""http_action.py
|
"""Defines `HTTPAction` for making HTTP requests using aiohttp.
|
||||||
Defines an Action subclass for making HTTP requests using aiohttp within Falyx workflows.
|
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
- Automatic reuse of aiohttp.ClientSession via SharedContext
|
- Automatic reuse of aiohttp.ClientSession via SharedContext
|
||||||
@@ -28,12 +27,11 @@ async def close_shared_http_session(context: ExecutionContext) -> None:
|
|||||||
if session and should_close:
|
if session and should_close:
|
||||||
await session.close()
|
await session.close()
|
||||||
except Exception as error:
|
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):
|
class HTTPAction(Action):
|
||||||
"""
|
"""An Action for executing HTTP requests using aiohttp with shared session reuse.
|
||||||
An Action for executing HTTP requests using aiohttp with shared session reuse.
|
|
||||||
|
|
||||||
This action integrates seamlessly into Falyx pipelines, with automatic session
|
This action integrates seamlessly into Falyx pipelines, with automatic session
|
||||||
management, result injection, and lifecycle hook support. It is ideal for CLI-driven
|
management, result injection, and lifecycle hook support. It is ideal for CLI-driven
|
||||||
@@ -47,7 +45,7 @@ class HTTPAction(Action):
|
|||||||
- Retry and result injection compatible
|
- Retry and result injection compatible
|
||||||
|
|
||||||
Args:
|
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').
|
method (str): HTTP method (e.g., 'GET', 'POST').
|
||||||
url (str): The request URL.
|
url (str): The request URL.
|
||||||
headers (dict[str, str], optional): Request headers.
|
headers (dict[str, str], optional): Request headers.
|
||||||
@@ -77,6 +75,11 @@ class HTTPAction(Action):
|
|||||||
inject_into: str = "last_result",
|
inject_into: str = "last_result",
|
||||||
retry: bool = False,
|
retry: bool = False,
|
||||||
retry_policy=None,
|
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.method = method.upper()
|
||||||
self.url = url
|
self.url = url
|
||||||
@@ -95,6 +98,11 @@ class HTTPAction(Action):
|
|||||||
inject_into=inject_into,
|
inject_into=inject_into,
|
||||||
retry=retry,
|
retry=retry,
|
||||||
retry_policy=retry_policy,
|
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]:
|
async def _request(self, *_, **__) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""io_action.py
|
"""BaseIOAction: A base class for stream- or buffer-based IO-driven Actions.
|
||||||
BaseIOAction: A base class for stream- or buffer-based IO-driven Actions.
|
|
||||||
|
|
||||||
This module defines `BaseIOAction`, a specialized variant of `BaseAction`
|
This module defines `BaseIOAction`, a specialized variant of `BaseAction`
|
||||||
that interacts with standard input and output, enabling command-line pipelines,
|
that interacts with standard input and output, enabling command-line pipelines,
|
||||||
@@ -16,25 +15,20 @@ Common usage includes shell-like filters, input transformers, or any tool that
|
|||||||
needs to consume input from another process or pipeline.
|
needs to consume input from another process or pipeline.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import shlex
|
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
from falyx.action.action import BaseAction
|
from falyx.action.base_action import BaseAction
|
||||||
from falyx.context import ExecutionContext
|
from falyx.context import ExecutionContext
|
||||||
from falyx.exceptions import FalyxError
|
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
from falyx.hook_manager import HookManager, HookType
|
from falyx.hook_manager import HookManager, HookType
|
||||||
from falyx.logger import logger
|
|
||||||
from falyx.themes import OneColors
|
from falyx.themes import OneColors
|
||||||
|
|
||||||
|
|
||||||
class BaseIOAction(BaseAction):
|
class BaseIOAction(BaseAction):
|
||||||
"""
|
"""Base class for IO-driven Actions that operate on stdin/stdout input streams.
|
||||||
Base class for IO-driven Actions that operate on stdin/stdout input streams.
|
|
||||||
|
|
||||||
Designed for use in shell pipelines or programmatic workflows that pass data
|
Designed for use in shell pipelines or programmatic workflows that pass data
|
||||||
through chained commands. It handles reading input, transforming it, and
|
through chained commands. It handles reading input, transforming it, and
|
||||||
@@ -52,8 +46,11 @@ class BaseIOAction(BaseAction):
|
|||||||
- `to_output(data)`: Convert result into output string or bytes.
|
- `to_output(data)`: Convert result into output string or bytes.
|
||||||
- `_run(parsed_input, *args, **kwargs)`: Core execution logic.
|
- `_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.
|
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.
|
inject_last_result (bool): Whether to inject shared context input.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -93,10 +90,7 @@ class BaseIOAction(BaseAction):
|
|||||||
if self.inject_last_result and self.shared_context:
|
if self.inject_last_result and self.shared_context:
|
||||||
return self.shared_context.last_result()
|
return self.shared_context.last_result()
|
||||||
|
|
||||||
logger.debug(
|
return ""
|
||||||
"[%s] No input provided and no last result found for injection.", self.name
|
|
||||||
)
|
|
||||||
raise FalyxError("No input provided and no last result to inject.")
|
|
||||||
|
|
||||||
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
|
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
|
||||||
return None, None
|
return None, None
|
||||||
@@ -174,92 +168,3 @@ class BaseIOAction(BaseAction):
|
|||||||
parent.add("".join(label))
|
parent.add("".join(label))
|
||||||
else:
|
else:
|
||||||
self.console.print(Tree("".join(label)))
|
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
|
|
||||||
|
|
||||||
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})"
|
|
||||||
)
|
|
||||||
|
|||||||
78
falyx/action/literal_input_action.py
Normal file
78
falyx/action/literal_input_action.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 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})"
|
||||||
263
falyx/action/load_file_action.py
Normal file
263
falyx/action/load_file_action.py
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 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)
|
||||||
|
raise
|
||||||
|
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 = await 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})"
|
||||||
@@ -1,26 +1,111 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 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 typing import Any
|
||||||
|
|
||||||
from prompt_toolkit import PromptSession
|
from prompt_toolkit import PromptSession
|
||||||
from rich.console import Console
|
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
from falyx.action.action import BaseAction
|
from falyx.action.base_action import BaseAction
|
||||||
from falyx.context import ExecutionContext
|
from falyx.context import ExecutionContext
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
from falyx.hook_manager import HookType
|
from falyx.hook_manager import HookType
|
||||||
from falyx.logger import logger
|
from falyx.logger import logger
|
||||||
from falyx.menu import MenuOptionMap
|
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.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.themes import OneColors
|
||||||
from falyx.utils import chunks
|
from falyx.utils import chunks
|
||||||
|
|
||||||
|
|
||||||
class MenuAction(BaseAction):
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -33,7 +118,6 @@ class MenuAction(BaseAction):
|
|||||||
default_selection: str = "",
|
default_selection: str = "",
|
||||||
inject_last_result: bool = False,
|
inject_last_result: bool = False,
|
||||||
inject_into: str = "last_result",
|
inject_into: str = "last_result",
|
||||||
console: Console | None = None,
|
|
||||||
prompt_session: PromptSession | None = None,
|
prompt_session: PromptSession | None = None,
|
||||||
never_prompt: bool = False,
|
never_prompt: bool = False,
|
||||||
include_reserved: bool = True,
|
include_reserved: bool = True,
|
||||||
@@ -49,10 +133,11 @@ class MenuAction(BaseAction):
|
|||||||
self.menu_options = menu_options
|
self.menu_options = menu_options
|
||||||
self.title = title
|
self.title = title
|
||||||
self.columns = columns
|
self.columns = columns
|
||||||
self.prompt_message = prompt_message
|
self.prompt_message = rich_text_to_prompt_text(prompt_message)
|
||||||
self.default_selection = default_selection
|
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.include_reserved = include_reserved
|
||||||
self.show_table = show_table
|
self.show_table = show_table
|
||||||
self.custom_table = custom_table
|
self.custom_table = custom_table
|
||||||
@@ -108,15 +193,18 @@ class MenuAction(BaseAction):
|
|||||||
key = effective_default
|
key = effective_default
|
||||||
if not self.never_prompt:
|
if not self.never_prompt:
|
||||||
table = self._build_table()
|
table = self._build_table()
|
||||||
key = await prompt_for_selection(
|
key_ = await prompt_for_selection(
|
||||||
self.menu_options.keys(),
|
self.menu_options.keys(),
|
||||||
table,
|
table,
|
||||||
default_selection=self.default_selection,
|
default_selection=self.default_selection,
|
||||||
console=self.console,
|
|
||||||
prompt_session=self.prompt_session,
|
prompt_session=self.prompt_session,
|
||||||
prompt_message=self.prompt_message,
|
prompt_message=self.prompt_message,
|
||||||
show_table=self.show_table,
|
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]
|
option = self.menu_options[key]
|
||||||
result = await option.action(*args, **kwargs)
|
result = await option.action(*args, **kwargs)
|
||||||
context.result = result
|
context.result = result
|
||||||
@@ -124,10 +212,10 @@ class MenuAction(BaseAction):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
except BackSignal:
|
except BackSignal:
|
||||||
logger.debug("[%s][BackSignal] ← Returning to previous menu", self.name)
|
logger.debug("[%s][BackSignal] <- Returning to previous menu", self.name)
|
||||||
return None
|
return None
|
||||||
except QuitSignal:
|
except QuitSignal:
|
||||||
logger.debug("[%s][QuitSignal] ← Exiting application", self.name)
|
logger.debug("[%s][QuitSignal] <- Exiting application", self.name)
|
||||||
raise
|
raise
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
context.exception = error
|
context.exception = error
|
||||||
|
|||||||
179
falyx/action/process_action.py
Normal file
179
falyx/action/process_action.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 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})"
|
||||||
|
)
|
||||||
232
falyx/action/process_pool_action.py
Normal file
232
falyx/action/process_pool_action.py
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 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_concurrent=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})"
|
||||||
|
)
|
||||||
189
falyx/action/prompt_menu_action.py
Normal file
189
falyx/action/prompt_menu_action.py
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 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'})"
|
||||||
|
)
|
||||||
293
falyx/action/save_file_action.py
Normal file
293
falyx/action/save_file_action.py
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 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})"
|
||||||
@@ -1,5 +1,46 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 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
|
from __future__ import annotations
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
@@ -11,15 +52,15 @@ from typing import Any
|
|||||||
import toml
|
import toml
|
||||||
import yaml
|
import yaml
|
||||||
from prompt_toolkit import PromptSession
|
from prompt_toolkit import PromptSession
|
||||||
from rich.console import Console
|
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
from falyx.action.action import BaseAction
|
from falyx.action.action_types import FileType
|
||||||
from falyx.action.types import FileReturnType
|
from falyx.action.base_action import BaseAction
|
||||||
from falyx.context import ExecutionContext
|
from falyx.context import ExecutionContext
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
from falyx.hook_manager import HookType
|
from falyx.hook_manager import HookType
|
||||||
from falyx.logger import logger
|
from falyx.logger import logger
|
||||||
|
from falyx.prompt_utils import rich_text_to_prompt_text
|
||||||
from falyx.selection import (
|
from falyx.selection import (
|
||||||
SelectionOption,
|
SelectionOption,
|
||||||
prompt_for_selection,
|
prompt_for_selection,
|
||||||
@@ -30,8 +71,7 @@ from falyx.themes import OneColors
|
|||||||
|
|
||||||
|
|
||||||
class SelectFileAction(BaseAction):
|
class SelectFileAction(BaseAction):
|
||||||
"""
|
"""SelectFileAction allows users to select a file(s) from a directory and return:
|
||||||
SelectFileAction allows users to select a file from a directory and return:
|
|
||||||
- file content (as text, JSON, CSV, etc.)
|
- file content (as text, JSON, CSV, etc.)
|
||||||
- or the file path itself.
|
- or the file path itself.
|
||||||
|
|
||||||
@@ -50,8 +90,10 @@ class SelectFileAction(BaseAction):
|
|||||||
prompt_message (str): Message to display when prompting for selection.
|
prompt_message (str): Message to display when prompting for selection.
|
||||||
style (str): Style for the selection options.
|
style (str): Style for the selection options.
|
||||||
suffix_filter (str | None): Restrict to certain file types.
|
suffix_filter (str | None): Restrict to certain file types.
|
||||||
return_type (FileReturnType): What to return (path, content, parsed).
|
return_type (FileType): What to return (path, content, parsed).
|
||||||
console (Console | None): Console instance for output.
|
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.
|
prompt_session (PromptSession | None): Prompt session for user input.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -65,63 +107,84 @@ class SelectFileAction(BaseAction):
|
|||||||
prompt_message: str = "Choose > ",
|
prompt_message: str = "Choose > ",
|
||||||
style: str = OneColors.WHITE,
|
style: str = OneColors.WHITE,
|
||||||
suffix_filter: str | None = None,
|
suffix_filter: str | None = None,
|
||||||
return_type: FileReturnType | str = FileReturnType.PATH,
|
return_type: FileType | str = FileType.PATH,
|
||||||
console: Console | None = None,
|
encoding: str = "UTF-8",
|
||||||
|
number_selections: int | str = 1,
|
||||||
|
separator: str = ",",
|
||||||
|
allow_duplicates: bool = False,
|
||||||
prompt_session: PromptSession | None = None,
|
prompt_session: PromptSession | None = None,
|
||||||
):
|
):
|
||||||
super().__init__(name)
|
super().__init__(name)
|
||||||
self.directory = Path(directory).resolve()
|
self.directory = Path(directory).resolve()
|
||||||
self.title = title
|
self.title = title
|
||||||
self.columns = columns
|
self.columns = columns
|
||||||
self.prompt_message = prompt_message
|
self.prompt_message = rich_text_to_prompt_text(prompt_message)
|
||||||
self.suffix_filter = suffix_filter
|
self.suffix_filter = suffix_filter
|
||||||
self.style = style
|
self.style = style
|
||||||
self.console = console or Console(color_system="auto")
|
self.number_selections = number_selections
|
||||||
self.prompt_session = prompt_session or PromptSession()
|
self.separator = separator
|
||||||
self.return_type = self._coerce_return_type(return_type)
|
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:
|
@property
|
||||||
if isinstance(return_type, FileReturnType):
|
def number_selections(self) -> int | str:
|
||||||
return return_type
|
return self._number_selections
|
||||||
return FileReturnType(return_type)
|
|
||||||
|
@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]:
|
def get_options(self, files: list[Path]) -> dict[str, SelectionOption]:
|
||||||
value: Any
|
|
||||||
options = {}
|
options = {}
|
||||||
for index, file in enumerate(files):
|
for index, file in enumerate(files):
|
||||||
try:
|
options[str(index)] = SelectionOption(
|
||||||
if self.return_type == FileReturnType.TEXT:
|
description=file.name,
|
||||||
value = file.read_text(encoding="UTF-8")
|
value=file, # Store the Path only — parsing will happen later
|
||||||
elif self.return_type == FileReturnType.PATH:
|
style=self.style,
|
||||||
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:
|
|
||||||
reader = csv.reader(csvfile)
|
|
||||||
value = list(reader)
|
|
||||||
elif self.return_type == FileReturnType.TSV:
|
|
||||||
with open(file, newline="", encoding="UTF-8") as tsvfile:
|
|
||||||
reader = csv.reader(tsvfile, delimiter="\t")
|
|
||||||
value = list(reader)
|
|
||||||
elif self.return_type == FileReturnType.XML:
|
|
||||||
tree = ET.parse(file, parser=ET.XMLParser(encoding="UTF-8"))
|
|
||||||
root = tree.getroot()
|
|
||||||
value = ET.tostring(root, encoding="unicode")
|
|
||||||
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
|
return options
|
||||||
|
|
||||||
|
def parse_file(self, file: Path) -> Any:
|
||||||
|
value: Any
|
||||||
|
try:
|
||||||
|
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 == 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 == FileType.TSV:
|
||||||
|
with open(file, newline="", encoding=self.encoding) as tsvfile:
|
||||||
|
reader = csv.reader(tsvfile, delimiter="\t")
|
||||||
|
value = list(reader)
|
||||||
|
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}")
|
||||||
|
except Exception as error:
|
||||||
|
logger.error("Failed to parse %s: %s", file.name, error)
|
||||||
|
return value
|
||||||
|
|
||||||
def _find_cancel_key(self, options) -> str:
|
def _find_cancel_key(self, options) -> str:
|
||||||
"""Return first numeric value not already used in the selection dict."""
|
"""Return first numeric value not already used in the selection dict."""
|
||||||
for index in range(len(options)):
|
for index in range(len(options)):
|
||||||
@@ -138,6 +201,11 @@ class SelectFileAction(BaseAction):
|
|||||||
try:
|
try:
|
||||||
await self.hooks.trigger(HookType.BEFORE, context)
|
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 = [
|
files = [
|
||||||
file
|
file
|
||||||
for file in self.directory.iterdir()
|
for file in self.directory.iterdir()
|
||||||
@@ -160,18 +228,24 @@ class SelectFileAction(BaseAction):
|
|||||||
title=self.title, selections=options | cancel_option, columns=self.columns
|
title=self.title, selections=options | cancel_option, columns=self.columns
|
||||||
)
|
)
|
||||||
|
|
||||||
key = await prompt_for_selection(
|
keys = await prompt_for_selection(
|
||||||
(options | cancel_option).keys(),
|
(options | cancel_option).keys(),
|
||||||
table,
|
table,
|
||||||
console=self.console,
|
|
||||||
prompt_session=self.prompt_session,
|
prompt_session=self.prompt_session,
|
||||||
prompt_message=self.prompt_message,
|
prompt_message=self.prompt_message,
|
||||||
|
number_selections=self.number_selections,
|
||||||
|
separator=self.separator,
|
||||||
|
allow_duplicates=self.allow_duplicates,
|
||||||
|
cancel_key=cancel_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
if key == cancel_key:
|
if isinstance(keys, str):
|
||||||
raise CancelSignal("User canceled the selection.")
|
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]
|
||||||
|
|
||||||
result = options[key].value
|
|
||||||
context.result = result
|
context.result = result
|
||||||
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||||
return result
|
return result
|
||||||
@@ -186,7 +260,7 @@ class SelectFileAction(BaseAction):
|
|||||||
er.record(context)
|
er.record(context)
|
||||||
|
|
||||||
async def preview(self, parent: Tree | None = None):
|
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 = parent.add(label) if parent else Tree(label)
|
||||||
|
|
||||||
tree.add(f"[dim]Directory:[/] {str(self.directory)}")
|
tree.add(f"[dim]Directory:[/] {str(self.directory)}")
|
||||||
@@ -194,6 +268,7 @@ class SelectFileAction(BaseAction):
|
|||||||
tree.add(f"[dim]Return type:[/] {self.return_type}")
|
tree.add(f"[dim]Return type:[/] {self.return_type}")
|
||||||
tree.add(f"[dim]Prompt:[/] {self.prompt_message}")
|
tree.add(f"[dim]Prompt:[/] {self.prompt_message}")
|
||||||
tree.add(f"[dim]Columns:[/] {self.columns}")
|
tree.add(f"[dim]Columns:[/] {self.columns}")
|
||||||
|
tree.add("[dim]Loading:[/] Lazy (parsing occurs after selection)")
|
||||||
try:
|
try:
|
||||||
files = list(self.directory.iterdir())
|
files = list(self.directory.iterdir())
|
||||||
if self.suffix_filter:
|
if self.suffix_filter:
|
||||||
@@ -212,6 +287,6 @@ class SelectFileAction(BaseAction):
|
|||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return (
|
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})"
|
f"suffix_filter={self.suffix_filter!r}, return_type={self.return_type})"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,47 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 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 typing import Any
|
||||||
|
|
||||||
from prompt_toolkit import PromptSession
|
from prompt_toolkit import PromptSession
|
||||||
from rich.console import Console
|
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
from falyx.action.action import BaseAction
|
from falyx.action.action_types import SelectionReturnType
|
||||||
from falyx.action.types import SelectionReturnType
|
from falyx.action.base_action import BaseAction
|
||||||
from falyx.context import ExecutionContext
|
from falyx.context import ExecutionContext
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
from falyx.hook_manager import HookType
|
from falyx.hook_manager import HookType
|
||||||
from falyx.logger import logger
|
from falyx.logger import logger
|
||||||
|
from falyx.prompt_utils import rich_text_to_prompt_text
|
||||||
from falyx.selection import (
|
from falyx.selection import (
|
||||||
SelectionOption,
|
SelectionOption,
|
||||||
SelectionOptionMap,
|
SelectionOptionMap,
|
||||||
@@ -25,12 +55,65 @@ from falyx.themes import OneColors
|
|||||||
|
|
||||||
|
|
||||||
class SelectionAction(BaseAction):
|
class SelectionAction(BaseAction):
|
||||||
"""
|
"""A Falyx Action for interactively or programmatically selecting one or more
|
||||||
A selection action that prompts the user to select an option from a list or
|
items from a list or dictionary of options.
|
||||||
dictionary. The selected option is then returned as the result of the action.
|
|
||||||
|
|
||||||
If return_key is True, the key of the selected option is returned instead of
|
`SelectionAction` supports both `list[str]` and `dict[str, SelectionOption]`
|
||||||
the value.
|
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. Options:
|
||||||
|
- 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}.
|
||||||
|
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__(
|
def __init__(
|
||||||
@@ -47,11 +130,13 @@ class SelectionAction(BaseAction):
|
|||||||
title: str = "Select an option",
|
title: str = "Select an option",
|
||||||
columns: int = 5,
|
columns: int = 5,
|
||||||
prompt_message: str = "Select > ",
|
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_last_result: bool = False,
|
||||||
inject_into: str = "last_result",
|
inject_into: str = "last_result",
|
||||||
return_type: SelectionReturnType | str = "value",
|
return_type: SelectionReturnType | str = "value",
|
||||||
console: Console | None = None,
|
|
||||||
prompt_session: PromptSession | None = None,
|
prompt_session: PromptSession | None = None,
|
||||||
never_prompt: bool = False,
|
never_prompt: bool = False,
|
||||||
show_table: bool = True,
|
show_table: bool = True,
|
||||||
@@ -64,21 +149,33 @@ class SelectionAction(BaseAction):
|
|||||||
)
|
)
|
||||||
# Setter normalizes to correct type, mypy can't infer that
|
# Setter normalizes to correct type, mypy can't infer that
|
||||||
self.selections: list[str] | SelectionOptionMap = selections # type: ignore[assignment]
|
self.selections: list[str] | SelectionOptionMap = selections # type: ignore[assignment]
|
||||||
self.return_type: SelectionReturnType = self._coerce_return_type(return_type)
|
self.return_type: SelectionReturnType = SelectionReturnType(return_type)
|
||||||
self.title = title
|
self.title = title
|
||||||
self.columns = columns
|
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.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
|
self.show_table = show_table
|
||||||
|
|
||||||
def _coerce_return_type(
|
@property
|
||||||
self, return_type: SelectionReturnType | str
|
def number_selections(self) -> int | str:
|
||||||
) -> SelectionReturnType:
|
return self._number_selections
|
||||||
if isinstance(return_type, SelectionReturnType):
|
|
||||||
return return_type
|
@number_selections.setter
|
||||||
return SelectionReturnType(return_type)
|
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
|
@property
|
||||||
def selections(self) -> list[str] | SelectionOptionMap:
|
def selections(self) -> list[str] | SelectionOptionMap:
|
||||||
@@ -115,15 +212,164 @@ class SelectionAction(BaseAction):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _find_cancel_key(self) -> str:
|
def _find_cancel_key(self) -> str:
|
||||||
"""Return first numeric value not already used in the selection dict."""
|
"""Find the cancel key in the selections."""
|
||||||
for index in range(len(self.selections)):
|
if isinstance(self.selections, dict):
|
||||||
if str(index) not in self.selections:
|
for index in range(len(self.selections) + 1):
|
||||||
return str(index)
|
if str(index) not in self.selections:
|
||||||
|
return str(index)
|
||||||
return str(len(self.selections))
|
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]:
|
def get_infer_target(self) -> tuple[None, None]:
|
||||||
return 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 and maybe_result is not None
|
||||||
|
]
|
||||||
|
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:
|
async def _run(self, *args, **kwargs) -> Any:
|
||||||
kwargs = self._maybe_inject_last_result(kwargs)
|
kwargs = self._maybe_inject_last_result(kwargs)
|
||||||
context = ExecutionContext(
|
context = ExecutionContext(
|
||||||
@@ -133,63 +379,68 @@ class SelectionAction(BaseAction):
|
|||||||
action=self,
|
action=self,
|
||||||
)
|
)
|
||||||
|
|
||||||
effective_default = str(self.default_selection)
|
effective_default = await self._resolve_effective_default()
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.never_prompt and not effective_default:
|
if self.never_prompt and not effective_default:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"[{self.name}] 'never_prompt' is True but no valid default_selection "
|
f"[{self.name}] 'never_prompt' is True but no valid default_selection "
|
||||||
"was provided."
|
"or usable last_result was available."
|
||||||
)
|
)
|
||||||
|
|
||||||
context.start_timer()
|
context.start_timer()
|
||||||
try:
|
try:
|
||||||
cancel_key = self._find_cancel_key()
|
self.cancel_key = self._find_cancel_key()
|
||||||
await self.hooks.trigger(HookType.BEFORE, context)
|
await self.hooks.trigger(HookType.BEFORE, context)
|
||||||
if isinstance(self.selections, list):
|
if isinstance(self.selections, list):
|
||||||
table = render_selection_indexed_table(
|
table = render_selection_indexed_table(
|
||||||
title=self.title,
|
title=self.title,
|
||||||
selections=self.selections + ["Cancel"],
|
selections=self.selections + ["Cancel"],
|
||||||
columns=self.columns,
|
columns=self.columns,
|
||||||
|
formatter=self.cancel_formatter,
|
||||||
)
|
)
|
||||||
|
if effective_default is None or isinstance(effective_default, int):
|
||||||
|
effective_default = ""
|
||||||
|
|
||||||
if not self.never_prompt:
|
if not self.never_prompt:
|
||||||
index = await prompt_for_index(
|
indices: int | list[int] = await prompt_for_index(
|
||||||
len(self.selections),
|
len(self.selections),
|
||||||
table,
|
table,
|
||||||
default_selection=effective_default,
|
default_selection=effective_default,
|
||||||
console=self.console,
|
|
||||||
prompt_session=self.prompt_session,
|
prompt_session=self.prompt_session,
|
||||||
prompt_message=self.prompt_message,
|
prompt_message=self.prompt_message,
|
||||||
show_table=self.show_table,
|
show_table=self.show_table,
|
||||||
|
number_selections=self.number_selections,
|
||||||
|
separator=self.separator,
|
||||||
|
allow_duplicates=self.allow_duplicates,
|
||||||
|
cancel_key=self.cancel_key,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
index = effective_default
|
if effective_default and self.number_selections == 1:
|
||||||
if index == cancel_key:
|
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.")
|
raise CancelSignal("User cancelled the selection.")
|
||||||
result: Any = self.selections[int(index)]
|
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):
|
elif isinstance(self.selections, dict):
|
||||||
cancel_option = {
|
cancel_option = {
|
||||||
cancel_key: SelectionOption(
|
self.cancel_key: SelectionOption(
|
||||||
description="Cancel", value=CancelSignal, style=OneColors.DARK_RED
|
description="Cancel", value=CancelSignal, style=OneColors.DARK_RED
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -199,33 +450,32 @@ class SelectionAction(BaseAction):
|
|||||||
columns=self.columns,
|
columns=self.columns,
|
||||||
)
|
)
|
||||||
if not self.never_prompt:
|
if not self.never_prompt:
|
||||||
key = await prompt_for_selection(
|
keys = await prompt_for_selection(
|
||||||
(self.selections | cancel_option).keys(),
|
(self.selections | cancel_option).keys(),
|
||||||
table,
|
table,
|
||||||
default_selection=effective_default,
|
default_selection=effective_default,
|
||||||
console=self.console,
|
|
||||||
prompt_session=self.prompt_session,
|
prompt_session=self.prompt_session,
|
||||||
prompt_message=self.prompt_message,
|
prompt_message=self.prompt_message,
|
||||||
show_table=self.show_table,
|
show_table=self.show_table,
|
||||||
|
number_selections=self.number_selections,
|
||||||
|
separator=self.separator,
|
||||||
|
allow_duplicates=self.allow_duplicates,
|
||||||
|
cancel_key=self.cancel_key,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
key = effective_default
|
if effective_default and self.number_selections == 1:
|
||||||
if key == cancel_key:
|
keys = effective_default
|
||||||
|
elif effective_default:
|
||||||
|
keys = effective_default.split(self.separator)
|
||||||
|
else:
|
||||||
|
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.")
|
raise CancelSignal("User cancelled the selection.")
|
||||||
if self.return_type == SelectionReturnType.KEY:
|
|
||||||
result = key
|
result = self._get_result_from_keys(keys)
|
||||||
elif self.return_type == SelectionReturnType.VALUE:
|
|
||||||
result = self.selections[key].value
|
|
||||||
elif self.return_type == SelectionReturnType.ITEMS:
|
|
||||||
result = {key: self.selections[key]}
|
|
||||||
elif self.return_type == SelectionReturnType.DESCRIPTION:
|
|
||||||
result = self.selections[key].description
|
|
||||||
elif self.return_type == SelectionReturnType.DESCRIPTION_VALUE:
|
|
||||||
result = {
|
|
||||||
self.selections[key].description: self.selections[key].value
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unsupported return type: {self.return_type}")
|
|
||||||
else:
|
else:
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
"'selections' must be a list[str] or dict[str, Any], "
|
"'selections' must be a list[str] or dict[str, Any], "
|
||||||
@@ -250,13 +500,13 @@ class SelectionAction(BaseAction):
|
|||||||
|
|
||||||
if isinstance(self.selections, list):
|
if isinstance(self.selections, list):
|
||||||
sub = tree.add(f"[dim]Type:[/] List[str] ({len(self.selections)} items)")
|
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}")
|
sub.add(f"[dim]{i}[/]: {item}")
|
||||||
if len(self.selections) > 10:
|
if len(self.selections) > 10:
|
||||||
sub.add(f"[dim]... ({len(self.selections) - 10} more)[/]")
|
sub.add(f"[dim]... ({len(self.selections) - 10} more)[/]")
|
||||||
elif isinstance(self.selections, dict):
|
elif isinstance(self.selections, dict):
|
||||||
sub = tree.add(
|
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]):
|
for i, (key, option) in enumerate(list(self.selections.items())[:10]):
|
||||||
sub.add(f"[dim]{key}[/]: {option.description}")
|
sub.add(f"[dim]{key}[/]: {option.description}")
|
||||||
@@ -266,9 +516,30 @@ class SelectionAction(BaseAction):
|
|||||||
tree.add(f"[{OneColors.DARK_RED_b}]Invalid selections type[/]")
|
tree.add(f"[{OneColors.DARK_RED_b}]Invalid selections type[/]")
|
||||||
return
|
return
|
||||||
|
|
||||||
tree.add(f"[dim]Default:[/] '{self.default_selection or self.last_result}'")
|
default = self.default_selection or self.last_result
|
||||||
tree.add(f"[dim]Return:[/] {self.return_type.name.capitalize()}")
|
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]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:
|
if not parent:
|
||||||
self.console.print(tree)
|
self.console.print(tree)
|
||||||
|
|||||||
103
falyx/action/shell_action.py
Normal file
103
falyx/action/shell_action.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 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})"
|
||||||
|
)
|
||||||
@@ -1,32 +1,81 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 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 rich.tree import Tree
|
||||||
|
|
||||||
from falyx.action.action import Action
|
from falyx.action.action import Action
|
||||||
|
from falyx.hook_manager import HookManager
|
||||||
from falyx.signals import FlowSignal
|
from falyx.signals import FlowSignal
|
||||||
from falyx.themes import OneColors
|
from falyx.themes import OneColors
|
||||||
|
|
||||||
|
|
||||||
class SignalAction(Action):
|
class SignalAction(Action):
|
||||||
"""
|
"""A hook-compatible action that raises a control flow signal when invoked.
|
||||||
An action that raises a control flow signal when executed.
|
|
||||||
|
|
||||||
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
|
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):
|
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
|
raise self.signal
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def signal(self):
|
def signal(self):
|
||||||
|
"""Returns the configured `FlowSignal` instance."""
|
||||||
return self._signal
|
return self._signal
|
||||||
|
|
||||||
@signal.setter
|
@signal.setter
|
||||||
def signal(self, value: FlowSignal):
|
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):
|
if not isinstance(value, FlowSignal):
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
f"Signal must be an FlowSignal instance, got {type(value).__name__}"
|
f"Signal must be an FlowSignal instance, got {type(value).__name__}"
|
||||||
|
|||||||
@@ -1,52 +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}")
|
|
||||||
|
|
||||||
|
|
||||||
class SelectionReturnType(Enum):
|
|
||||||
"""Enum for dictionary return types."""
|
|
||||||
|
|
||||||
KEY = "key"
|
|
||||||
VALUE = "value"
|
|
||||||
DESCRIPTION = "description"
|
|
||||||
DESCRIPTION_VALUE = "description_value"
|
|
||||||
ITEMS = "items"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _missing_(cls, value: object) -> SelectionReturnType:
|
|
||||||
valid = ", ".join(member.value for member in cls)
|
|
||||||
raise ValueError(f"Invalid DictReturnType: '{value}'. Must be one of: {valid}")
|
|
||||||
@@ -1,36 +1,68 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 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 import PromptSession
|
||||||
from prompt_toolkit.validation import Validator
|
from prompt_toolkit.validation import Validator
|
||||||
from rich.console import Console
|
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
from falyx.action import BaseAction
|
from falyx.action.base_action import BaseAction
|
||||||
from falyx.context import ExecutionContext
|
from falyx.context import ExecutionContext
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
from falyx.hook_manager import HookType
|
from falyx.hook_manager import HookType
|
||||||
|
from falyx.prompt_utils import rich_text_to_prompt_text
|
||||||
|
from falyx.signals import CancelSignal
|
||||||
from falyx.themes.colors import OneColors
|
from falyx.themes.colors import OneColors
|
||||||
|
|
||||||
|
|
||||||
class UserInputAction(BaseAction):
|
class UserInputAction(BaseAction):
|
||||||
"""
|
"""Prompts the user for textual input and returns their response.
|
||||||
Prompts the user for input via PromptSession and returns the result.
|
|
||||||
|
`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:
|
Args:
|
||||||
name (str): Action name.
|
name (str): Name of the action (used for introspection and logging).
|
||||||
prompt_text (str): Prompt text (can include '{last_result}' for interpolation).
|
prompt_message (str): The prompt message shown to the user.
|
||||||
validator (Validator, optional): Prompt Toolkit validator.
|
Can include `{last_result}` if `inject_last_result=True`.
|
||||||
console (Console, optional): Rich console for rendering.
|
default_text (str): Optional default value shown in the prompt.
|
||||||
prompt_session (PromptSession, optional): Reusable prompt session.
|
validator (Validator | None): Prompt Toolkit validator for input constraints.
|
||||||
inject_last_result (bool): Whether to inject last_result into prompt.
|
prompt_session (PromptSession | None): Optional custom prompt session.
|
||||||
inject_into (str): Key to use for injection (default: 'last_result').
|
inject_last_result (bool): Whether to inject `last_result` into the prompt.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
*,
|
*,
|
||||||
prompt_text: str = "Input > ",
|
prompt_message: str = "Input > ",
|
||||||
|
default_text: str = "",
|
||||||
|
multiline: bool = False,
|
||||||
validator: Validator | None = None,
|
validator: Validator | None = None,
|
||||||
console: Console | None = None,
|
|
||||||
prompt_session: PromptSession | None = None,
|
prompt_session: PromptSession | None = None,
|
||||||
inject_last_result: bool = False,
|
inject_last_result: bool = False,
|
||||||
):
|
):
|
||||||
@@ -38,10 +70,13 @@ class UserInputAction(BaseAction):
|
|||||||
name=name,
|
name=name,
|
||||||
inject_last_result=inject_last_result,
|
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.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]:
|
def get_infer_target(self) -> tuple[None, None]:
|
||||||
return None, None
|
return None, None
|
||||||
@@ -57,13 +92,15 @@ class UserInputAction(BaseAction):
|
|||||||
try:
|
try:
|
||||||
await self.hooks.trigger(HookType.BEFORE, context)
|
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:
|
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(
|
answer = await self.prompt_session.prompt_async(
|
||||||
prompt_text,
|
rich_text_to_prompt_text(prompt_message),
|
||||||
validator=self.validator,
|
validator=self.validator,
|
||||||
|
default=kwargs.get("default_text", self.default_text),
|
||||||
|
multiline=self.multiline,
|
||||||
)
|
)
|
||||||
context.result = answer
|
context.result = answer
|
||||||
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||||
@@ -82,12 +119,12 @@ class UserInputAction(BaseAction):
|
|||||||
label = f"[{OneColors.MAGENTA}]⌨ UserInputAction[/] '{self.name}'"
|
label = f"[{OneColors.MAGENTA}]⌨ UserInputAction[/] '{self.name}'"
|
||||||
tree = parent.add(label) if parent else Tree(label)
|
tree = parent.add(label) if parent else Tree(label)
|
||||||
|
|
||||||
prompt_text = (
|
prompt_message = (
|
||||||
self.prompt_text.replace("{last_result}", "<last_result>")
|
self.prompt_message.replace("{last_result}", "<last_result>")
|
||||||
if "{last_result}" in self.prompt_text
|
if "{last_result}" in self.prompt_message
|
||||||
else self.prompt_text
|
else self.prompt_message
|
||||||
)
|
)
|
||||||
tree.add(f"[dim]Prompt:[/] {prompt_text}")
|
tree.add(f"[dim]Prompt:[/] {prompt_message}")
|
||||||
if self.validator:
|
if self.validator:
|
||||||
tree.add("[dim]Validator:[/] Yes")
|
tree.add("[dim]Validator:[/] Yes")
|
||||||
if not parent:
|
if not parent:
|
||||||
|
|||||||
@@ -1,12 +1,46 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 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
|
||||||
|
|
||||||
|
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 typing import Any, Callable
|
||||||
|
|
||||||
from prompt_toolkit.formatted_text import HTML, merge_formatted_text
|
from prompt_toolkit.formatted_text import HTML, merge_formatted_text
|
||||||
from prompt_toolkit.key_binding import KeyBindings
|
from prompt_toolkit.key_binding import KeyBindings
|
||||||
|
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
|
from falyx.console import console
|
||||||
from falyx.options_manager import OptionsManager
|
from falyx.options_manager import OptionsManager
|
||||||
from falyx.themes import OneColors
|
from falyx.themes import OneColors
|
||||||
from falyx.utils import CaseInsensitiveDict, chunks
|
from falyx.utils import CaseInsensitiveDict, chunks
|
||||||
@@ -23,19 +57,24 @@ class BottomBar:
|
|||||||
Must return True if key is available, otherwise False.
|
Must return True if key is available, otherwise False.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
RESERVED_CTRL_KEYS = {"c", "d", "z", "v"}
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
columns: int = 3,
|
columns: int = 3,
|
||||||
key_bindings: KeyBindings | None = None,
|
key_bindings: KeyBindings | None = None,
|
||||||
key_validator: Callable[[str], bool] | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self.columns = columns
|
self.columns = columns
|
||||||
self.console = Console(color_system="auto")
|
self.console: Console = console
|
||||||
self._named_items: dict[str, Callable[[], HTML]] = {}
|
self._named_items: dict[str, Callable[[], HTML]] = {}
|
||||||
self._value_getters: dict[str, Callable[[], Any]] = CaseInsensitiveDict()
|
self._value_getters: dict[str, Callable[[], Any]] = CaseInsensitiveDict()
|
||||||
self.toggle_keys: list[str] = []
|
self.toggle_keys: list[str] = []
|
||||||
self.key_bindings = key_bindings or KeyBindings()
|
self.key_bindings = key_bindings or KeyBindings()
|
||||||
self.key_validator = key_validator
|
|
||||||
|
@property
|
||||||
|
def has_items(self) -> bool:
|
||||||
|
"""Check if the bottom bar has any registered items."""
|
||||||
|
return bool(self._named_items)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def default_render(label: str, value: Any, fg: str, bg: str, width: int) -> HTML:
|
def default_render(label: str, value: Any, fg: str, bg: str, width: int) -> HTML:
|
||||||
@@ -120,17 +159,31 @@ class BottomBar:
|
|||||||
bg_on: str = OneColors.GREEN,
|
bg_on: str = OneColors.GREEN,
|
||||||
bg_off: str = OneColors.DARK_RED,
|
bg_off: str = OneColors.DARK_RED,
|
||||||
) -> None:
|
) -> 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):
|
if not callable(get_state):
|
||||||
raise ValueError("`get_state` must be a callable returning bool")
|
raise ValueError("`get_state` must be a callable returning bool")
|
||||||
if not callable(toggle_state):
|
if not callable(toggle_state):
|
||||||
raise ValueError("`toggle_state` must be a callable")
|
raise ValueError("`toggle_state` must be a callable")
|
||||||
key = key.upper()
|
|
||||||
if key in self.toggle_keys:
|
if key in self.toggle_keys:
|
||||||
raise ValueError(f"Key {key} is already used as a toggle")
|
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._value_getters[key] = get_state
|
||||||
self.toggle_keys.append(key)
|
self.toggle_keys.append(key)
|
||||||
|
|
||||||
@@ -138,16 +191,14 @@ class BottomBar:
|
|||||||
get_state_ = self._value_getters[key]
|
get_state_ = self._value_getters[key]
|
||||||
color = bg_on if get_state_() else bg_off
|
color = bg_on if get_state_() else bg_off
|
||||||
status = "ON" if get_state_() else "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>")
|
return HTML(f"<style bg='{color}' fg='{fg}'>{text:^{self.space}}</style>")
|
||||||
|
|
||||||
self._add_named(key, render)
|
self._add_named(key, render)
|
||||||
|
|
||||||
for k in (key.upper(), key.lower()):
|
@self.key_bindings.add(f"c-{key.lower()}", eager=True)
|
||||||
|
def _(_: KeyPressEvent):
|
||||||
@self.key_bindings.add(k)
|
toggle_state()
|
||||||
def _(_):
|
|
||||||
toggle_state()
|
|
||||||
|
|
||||||
def add_toggle_from_option(
|
def add_toggle_from_option(
|
||||||
self,
|
self,
|
||||||
@@ -155,7 +206,7 @@ class BottomBar:
|
|||||||
label: str,
|
label: str,
|
||||||
options: OptionsManager,
|
options: OptionsManager,
|
||||||
option_name: str,
|
option_name: str,
|
||||||
namespace_name: str = "cli_args",
|
namespace_name: str = "default",
|
||||||
fg: str = OneColors.BLACK,
|
fg: str = OneColors.BLACK,
|
||||||
bg_on: str = OneColors.GREEN,
|
bg_on: str = OneColors.GREEN,
|
||||||
bg_off: str = OneColors.DARK_RED,
|
bg_off: str = OneColors.DARK_RED,
|
||||||
|
|||||||
731
falyx/command.py
731
falyx/command.py
@@ -1,40 +1,68 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""command.py
|
"""Command abstraction for the Falyx CLI framework.
|
||||||
|
|
||||||
Defines the Command class for Falyx CLI.
|
This module defines the `Command` class, which represents a single executable
|
||||||
|
unit exposed to users via CLI or interactive menu interfaces.
|
||||||
|
|
||||||
Commands are callable units representing a menu option or CLI task,
|
A `Command` acts as a bridge between:
|
||||||
wrapping either a BaseAction or a simple function. They provide:
|
- User input (parsed via CommandArgumentParser)
|
||||||
|
- Execution logic (encapsulated in Action / BaseAction)
|
||||||
|
- Runtime configuration (OptionsManager)
|
||||||
|
- Lifecycle hooks (HookManager)
|
||||||
|
|
||||||
- Hook lifecycle (before, on_success, on_error, after, on_teardown)
|
Core Responsibilities:
|
||||||
|
- Define command identity (key, aliases, description)
|
||||||
|
- Bind an executable action or workflow
|
||||||
|
- Configure argument parsing via CommandArgumentParser
|
||||||
|
- Separate execution arguments (e.g. retries, confirm) from action arguments
|
||||||
|
- Manage lifecycle hooks for command-level execution
|
||||||
|
- Provide help, usage, and preview interfaces
|
||||||
- Execution timing and duration tracking
|
- Execution timing and duration tracking
|
||||||
- Retry logic (single action or recursively through action trees)
|
|
||||||
- Confirmation prompts and spinner integration
|
- Confirmation prompts and spinner integration
|
||||||
- Result capturing and summary logging
|
|
||||||
- Rich-based preview for CLI display
|
|
||||||
|
|
||||||
Every Command is self-contained, configurable, and plays a critical role
|
Execution Model:
|
||||||
in building robust interactive menus.
|
1. CLI input is routed via FalyxParser into a resolved Command
|
||||||
|
2. Arguments are parsed via CommandArgumentParser
|
||||||
|
3. Parsed values are split into:
|
||||||
|
- positional args
|
||||||
|
- keyword args
|
||||||
|
- execution args (e.g. retries, summary)
|
||||||
|
4. Execution occurs via the bound Action with lifecycle hooks applied
|
||||||
|
5. Results and context are tracked via ExecutionContext / ExecutionRegistry
|
||||||
|
|
||||||
|
Key Concepts:
|
||||||
|
- Commands are *user-facing entrypoints*, not execution units themselves
|
||||||
|
- Execution is always delegated to an underlying Action or callable
|
||||||
|
- Argument parsing is declarative and optional
|
||||||
|
- Execution options are handled separately from business logic inputs
|
||||||
|
|
||||||
|
This module defines the primary abstraction used by Falyx to expose structured,
|
||||||
|
composable workflows as CLI commands.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import shlex
|
import shlex
|
||||||
from typing import Any, Callable
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
from prompt_toolkit.formatted_text import FormattedText
|
from prompt_toolkit.formatted_text import FormattedText
|
||||||
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
|
||||||
from rich.console import Console
|
from rich.style import Style
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
from falyx.action.action import Action, BaseAction
|
from falyx.action.action import Action
|
||||||
from falyx.context import ExecutionContext
|
from falyx.action.base_action import BaseAction
|
||||||
|
from falyx.console import console
|
||||||
|
from falyx.context import ExecutionContext, InvocationContext
|
||||||
from falyx.debug import register_debug_hooks
|
from falyx.debug import register_debug_hooks
|
||||||
|
from falyx.exceptions import CommandArgumentError, InvalidHookError, NotAFalyxError
|
||||||
|
from falyx.execution_option import ExecutionOption
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
from falyx.hook_manager import HookManager, HookType
|
from falyx.hook_manager import HookManager, HookType
|
||||||
|
from falyx.hooks import spinner_before_hook, spinner_teardown_hook
|
||||||
from falyx.logger import logger
|
from falyx.logger import logger
|
||||||
from falyx.options_manager import OptionsManager
|
from falyx.options_manager import OptionsManager
|
||||||
from falyx.parsers.argparse import CommandArgumentParser
|
from falyx.parser.command_argument_parser import CommandArgumentParser
|
||||||
from falyx.parsers.signature import infer_args_from_func
|
from falyx.parser.signature import infer_args_from_func
|
||||||
from falyx.prompt_utils import confirm_async, should_prompt_user
|
from falyx.prompt_utils import confirm_async, should_prompt_user
|
||||||
from falyx.protocols import ArgParserProtocol
|
from falyx.protocols import ArgParserProtocol
|
||||||
from falyx.retry import RetryPolicy
|
from falyx.retry import RetryPolicy
|
||||||
@@ -43,83 +71,126 @@ from falyx.signals import CancelSignal
|
|||||||
from falyx.themes import OneColors
|
from falyx.themes import OneColors
|
||||||
from falyx.utils import ensure_async
|
from falyx.utils import ensure_async
|
||||||
|
|
||||||
console = Console(color_system="auto")
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseModel):
|
class Command(BaseModel):
|
||||||
"""
|
"""Represents a user-invokable command in Falyx.
|
||||||
Represents a selectable command in a Falyx menu system.
|
|
||||||
|
|
||||||
A Command wraps an executable action (function, coroutine, or BaseAction)
|
A `Command` encapsulates all metadata, parsing logic, and execution behavior
|
||||||
and enhances it with:
|
required to expose a callable workflow through the Falyx CLI or interactive
|
||||||
|
menu system.
|
||||||
|
|
||||||
- Lifecycle hooks (before, success, error, after, teardown)
|
It is responsible for:
|
||||||
- Retry support (single action or recursive for chained/grouped actions)
|
- Identifying the command via key and aliases
|
||||||
- Confirmation prompts for safe execution
|
- Binding an executable Action or callable
|
||||||
- Spinner visuals during execution
|
- Parsing user-provided arguments
|
||||||
- Tagging for categorization and filtering
|
- Managing execution configuration (retries, confirmation, etc.)
|
||||||
- Rich-based CLI previews
|
- Integrating with lifecycle hooks and execution context
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
- Parsing is delegated to CommandArgumentParser
|
||||||
|
- Execution is delegated to BaseAction / Action
|
||||||
|
- Runtime configuration is managed via OptionsManager
|
||||||
|
- Lifecycle hooks are managed via HookManager
|
||||||
|
|
||||||
|
Argument Handling:
|
||||||
|
- Supports positional and keyword arguments via CommandArgumentParser
|
||||||
|
- Separates execution-specific options (e.g. retries, confirm flags)
|
||||||
|
from action arguments
|
||||||
|
- Returns structured `(args, kwargs, execution_args)` for execution
|
||||||
|
|
||||||
|
Execution Behavior:
|
||||||
|
- Callable via `await command(*args, **kwargs)`
|
||||||
|
- Applies lifecycle hooks:
|
||||||
|
before → on_success/on_error → after → on_teardown
|
||||||
|
- Supports preview mode for dry-run introspection
|
||||||
|
- Supports retry policies and confirmation flows
|
||||||
- Result tracking and summary reporting
|
- Result tracking and summary reporting
|
||||||
|
|
||||||
Commands are built to be flexible yet robust, enabling dynamic CLI workflows
|
Help & Introspection:
|
||||||
without sacrificing control or reliability.
|
- Provides usage, help text, and TLDR examples
|
||||||
|
- Supports both CLI help and interactive menu rendering
|
||||||
|
- Can expose simplified or full help signatures
|
||||||
|
|
||||||
Attributes:
|
Args:
|
||||||
key (str): Primary trigger key for the command.
|
key (str): Primary identifier used to invoke the command.
|
||||||
description (str): Short description for the menu display.
|
description (str): Short description for the menu display.
|
||||||
hidden (bool): Toggles visibility in the menu.
|
action (BaseAction | Callable[..., Any]):
|
||||||
aliases (list[str]): Alternate keys or phrases.
|
Execution logic for the command.
|
||||||
action (BaseAction | Callable): The executable logic.
|
args (tuple, optional): Static positional arguments.
|
||||||
args (tuple): Static positional arguments.
|
kwargs (dict[str, Any], optional): Static keyword arguments.
|
||||||
kwargs (dict): Static keyword arguments.
|
hidden (bool): Whether to hide the command from menus.
|
||||||
help_text (str): Additional help or guidance text.
|
aliases (list[str], optional): Alternate names for invocation.
|
||||||
style (str): Rich style for description.
|
help_text (str): Help description shown in CLI/menu.
|
||||||
confirm (bool): Whether to require confirmation before executing.
|
help_epilog (str): Additional help content.
|
||||||
confirm_message (str): Custom confirmation prompt.
|
style (Style | str): Rich style used for rendering.
|
||||||
preview_before_confirm (bool): Whether to preview before confirming.
|
confirm (bool): Whether confirmation is required before execution.
|
||||||
spinner (bool): Whether to show a spinner during execution.
|
confirm_message (str): Confirmation prompt text.
|
||||||
spinner_message (str): Spinner text message.
|
preview_before_confirm (bool): Whether to preview before confirmation.
|
||||||
spinner_type (str): Spinner style (e.g., dots, line, etc.).
|
spinner (bool): Enable spinner during execution.
|
||||||
spinner_style (str): Color or style of the spinner.
|
spinner_message (str): Spinner message text.
|
||||||
spinner_kwargs (dict): Extra spinner configuration.
|
spinner_type (str): Rich Spinner animation type (e.g., dots, line, etc.).
|
||||||
hooks (HookManager): Hook manager for lifecycle events.
|
spinner_style (Style | str): Rich style for the spinner.
|
||||||
retry (bool): Enable retry on failure.
|
spinner_speed (float): Spinner speed multiplier.
|
||||||
retry_all (bool): Enable retry across chained or grouped actions.
|
hooks (HookManager | None): Hook manager for lifecycle events.
|
||||||
retry_policy (RetryPolicy): Retry behavior configuration.
|
tags (list[str], optional): Tags for grouping and filtering.
|
||||||
tags (list[str]): Organizational tags for the command.
|
logging_hooks (bool): Enable debug logging hooks.
|
||||||
logging_hooks (bool): Whether to attach logging hooks automatically.
|
retry (bool): Enable retry behavior.
|
||||||
options_manager (OptionsManager): Manages global command-line options.
|
retry_all (bool): Apply retry to all nested actions.
|
||||||
arg_parser (CommandArgumentParser): Parses command arguments.
|
retry_policy (RetryPolicy | None): Retry configuration.
|
||||||
custom_parser (ArgParserProtocol | None): Custom argument parser.
|
arg_parser (CommandArgumentParser | None):
|
||||||
custom_help (Callable[[], str | None] | None): Custom help message generator.
|
Custom argument parser instance.
|
||||||
auto_args (bool): Automatically infer arguments from the action.
|
execution_options (frozenset[ExecutionOption], optional):
|
||||||
|
Enabled execution-level options.
|
||||||
|
arguments (list[dict[str, Any]], optional):
|
||||||
|
Declarative argument definitions.
|
||||||
|
argument_config (Callable[[CommandArgumentParser], None] | None):
|
||||||
|
Callback to configure parser.
|
||||||
|
custom_parser (ArgParserProtocol | None):
|
||||||
|
Override parser logic entirely.
|
||||||
|
custom_help (Callable[[], str | None] | None):
|
||||||
|
Override help rendering.
|
||||||
|
custom_tldr (Callable[[], str | None] | None):
|
||||||
|
Override TLDR rendering.
|
||||||
|
custom_usage (Callable[[], str | None] | None):
|
||||||
|
Override usage rendering.
|
||||||
|
auto_args (bool): Auto-generate arguments from action signature.
|
||||||
|
arg_metadata (dict[str, Any], optional): Metadata for arguments.
|
||||||
|
simple_help_signature (bool): Use simplified help formatting.
|
||||||
|
ignore_in_history (bool):
|
||||||
|
Ignore command for `last_result` in execution history.
|
||||||
|
options_manager (OptionsManager | None):
|
||||||
|
Shared options manager instance.
|
||||||
|
program (str | None): The parent program name.
|
||||||
|
|
||||||
Methods:
|
Raises:
|
||||||
__call__(): Executes the command, respecting hooks and retries.
|
CommandArgumentError: If argument parsing fails.
|
||||||
preview(): Rich tree preview of the command.
|
InvalidActionError: If action is not callable or invalid.
|
||||||
confirmation_prompt(): Formatted prompt for confirmation.
|
FalyxError: If command configuration is invalid.
|
||||||
result: Property exposing the last result.
|
|
||||||
log_summary(): Summarizes execution details to the console.
|
Notes:
|
||||||
|
- Commands are lightweight wrappers; execution logic belongs in Actions
|
||||||
|
- Argument parsing and execution are intentionally decoupled
|
||||||
|
- Commands are case-insensitive and support alias resolution
|
||||||
"""
|
"""
|
||||||
|
|
||||||
key: str
|
key: str
|
||||||
description: str
|
description: str
|
||||||
action: BaseAction | Callable[..., Any]
|
action: BaseAction | Callable[..., Any] | Callable[..., Awaitable[Any]]
|
||||||
args: tuple = ()
|
args: tuple = ()
|
||||||
kwargs: dict[str, Any] = Field(default_factory=dict)
|
kwargs: dict[str, Any] = Field(default_factory=dict)
|
||||||
hidden: bool = False
|
hidden: bool = False
|
||||||
aliases: list[str] = Field(default_factory=list)
|
aliases: list[str] = Field(default_factory=list)
|
||||||
help_text: str = ""
|
help_text: str = ""
|
||||||
help_epilogue: str = ""
|
help_epilog: str = ""
|
||||||
style: str = OneColors.WHITE
|
style: Style | str = OneColors.WHITE
|
||||||
confirm: bool = False
|
confirm: bool = False
|
||||||
confirm_message: str = "Are you sure?"
|
confirm_message: str = "Are you sure?"
|
||||||
preview_before_confirm: bool = True
|
preview_before_confirm: bool = True
|
||||||
spinner: bool = False
|
spinner: bool = False
|
||||||
spinner_message: str = "Processing..."
|
spinner_message: str = "Processing..."
|
||||||
spinner_type: str = "dots"
|
spinner_type: str = "dots"
|
||||||
spinner_style: str = OneColors.CYAN
|
spinner_style: Style | str = OneColors.CYAN
|
||||||
spinner_kwargs: dict[str, Any] = Field(default_factory=dict)
|
spinner_speed: float = 1.0
|
||||||
hooks: "HookManager" = Field(default_factory=HookManager)
|
hooks: "HookManager" = Field(default_factory=HookManager)
|
||||||
retry: bool = False
|
retry: bool = False
|
||||||
retry_all: bool = False
|
retry_all: bool = False
|
||||||
@@ -127,59 +198,127 @@ class Command(BaseModel):
|
|||||||
tags: list[str] = Field(default_factory=list)
|
tags: list[str] = Field(default_factory=list)
|
||||||
logging_hooks: bool = False
|
logging_hooks: bool = False
|
||||||
options_manager: OptionsManager = Field(default_factory=OptionsManager)
|
options_manager: OptionsManager = Field(default_factory=OptionsManager)
|
||||||
arg_parser: CommandArgumentParser = Field(default_factory=CommandArgumentParser)
|
arg_parser: CommandArgumentParser | None = None
|
||||||
|
execution_options: frozenset[ExecutionOption] = Field(default_factory=frozenset)
|
||||||
arguments: list[dict[str, Any]] = Field(default_factory=list)
|
arguments: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
argument_config: Callable[[CommandArgumentParser], None] | None = None
|
argument_config: Callable[[CommandArgumentParser], None] | None = None
|
||||||
custom_parser: ArgParserProtocol | None = None
|
custom_parser: ArgParserProtocol | None = None
|
||||||
custom_help: Callable[[], str | None] | None = None
|
custom_help: Callable[[], str | None] | None = None
|
||||||
|
custom_tldr: Callable[[], str | None] | None = None
|
||||||
|
custom_usage: Callable[[], str | None] | None = None
|
||||||
auto_args: bool = True
|
auto_args: bool = True
|
||||||
arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict)
|
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)
|
_context: ExecutionContext | None = PrivateAttr(default=None)
|
||||||
|
|
||||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
|
|
||||||
def parse_args(
|
async def resolve_args(
|
||||||
self, raw_args: list[str] | str, from_validate: bool = False
|
self,
|
||||||
) -> tuple[tuple, dict]:
|
raw_args: list[str] | str,
|
||||||
if callable(self.custom_parser):
|
from_validate: bool = False,
|
||||||
|
invocation_context: InvocationContext | None = None,
|
||||||
|
) -> tuple[tuple, dict, dict]:
|
||||||
|
"""Parse CLI arguments into execution-ready components.
|
||||||
|
|
||||||
|
This method delegates argument parsing to the configured
|
||||||
|
CommandArgumentParser (if present) and normalizes the result into three
|
||||||
|
distinct groups used during execution:
|
||||||
|
|
||||||
|
- positional arguments (`args`)
|
||||||
|
- keyword arguments (`kwargs`)
|
||||||
|
- execution arguments (`execution_args`)
|
||||||
|
|
||||||
|
Execution arguments represent runtime configuration (e.g. retries,
|
||||||
|
confirmation flags, summary output) and are handled separately from the
|
||||||
|
action's business logic inputs.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- If an argument parser is defined, uses `CommandArgumentParser.parse_args_split()`
|
||||||
|
to resolve and type-coerce all inputs.
|
||||||
|
- If no parser is defined, returns empty args and kwargs.
|
||||||
|
- Supports validation mode (`from_validate=True`) for interactive input,
|
||||||
|
deferring certain errors and resolver execution where applicable.
|
||||||
|
- Handles help/preview signals raised during parsing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args (list[str] | str | None): CLI-style argument tokens or a single string.
|
||||||
|
from_validate (bool): Whether parsing is occurring in validation mode
|
||||||
|
(e.g. prompt_toolkit validator). When True, may suppress eager
|
||||||
|
resolution or defer certain errors.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple:
|
||||||
|
- tuple[Any, ...]: Positional arguments for execution.
|
||||||
|
- dict[str, Any]: Keyword arguments for execution.
|
||||||
|
- dict[str, Any]: Execution-specific arguments (e.g. retries,
|
||||||
|
confirm flags, summary).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
CommandArgumentError: If argument parsing or validation fails.
|
||||||
|
HelpSignal: If help or TLDR output is triggered during parsing.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Execution arguments are not passed to the underlying Action.
|
||||||
|
- This method is the canonical boundary between CLI parsing and
|
||||||
|
execution semantics.
|
||||||
|
"""
|
||||||
|
if self.custom_parser is not None:
|
||||||
|
if not callable(self.custom_parser):
|
||||||
|
raise NotAFalyxError(
|
||||||
|
"custom_parser must be a callable that implements ArgParserProtocol."
|
||||||
|
)
|
||||||
if isinstance(raw_args, str):
|
if isinstance(raw_args, str):
|
||||||
try:
|
try:
|
||||||
raw_args = shlex.split(raw_args)
|
raw_args = shlex.split(raw_args)
|
||||||
except ValueError:
|
except ValueError as error:
|
||||||
logger.warning(
|
raise CommandArgumentError(
|
||||||
"[Command:%s] Failed to split arguments: %s",
|
f"[{self.key}] Failed to parse arguments: {error}"
|
||||||
self.key,
|
) from error
|
||||||
raw_args,
|
|
||||||
)
|
|
||||||
return ((), {})
|
|
||||||
return self.custom_parser(raw_args)
|
return self.custom_parser(raw_args)
|
||||||
|
|
||||||
if isinstance(raw_args, str):
|
if isinstance(raw_args, str):
|
||||||
try:
|
try:
|
||||||
raw_args = shlex.split(raw_args)
|
raw_args = shlex.split(raw_args)
|
||||||
except ValueError:
|
except ValueError as error:
|
||||||
logger.warning(
|
raise CommandArgumentError(
|
||||||
"[Command:%s] Failed to split arguments: %s",
|
f"[{self.key}] Failed to parse arguments: {error}"
|
||||||
self.key,
|
) from error
|
||||||
raw_args,
|
|
||||||
)
|
if self.arg_parser is None:
|
||||||
return ((), {})
|
raise NotAFalyxError(
|
||||||
return self.arg_parser.parse_args_split(raw_args, from_validate=from_validate)
|
"Command has no parser configured. "
|
||||||
|
"Provide a custom_parser or CommandArgumentParser."
|
||||||
|
)
|
||||||
|
if not isinstance(self.arg_parser, CommandArgumentParser):
|
||||||
|
raise NotAFalyxError(
|
||||||
|
"arg_parser must be an instance of CommandArgumentParser"
|
||||||
|
)
|
||||||
|
|
||||||
|
return await self.arg_parser.parse_args_split(
|
||||||
|
raw_args,
|
||||||
|
from_validate=from_validate,
|
||||||
|
invocation_context=invocation_context,
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator("action", mode="before")
|
@field_validator("action", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def wrap_callable_as_async(cls, action: Any) -> Any:
|
def _wrap_callable_as_async(cls, action: Any) -> Any:
|
||||||
if isinstance(action, BaseAction):
|
if isinstance(action, BaseAction):
|
||||||
return action
|
return action
|
||||||
elif callable(action):
|
elif callable(action):
|
||||||
return ensure_async(action)
|
return ensure_async(action)
|
||||||
raise TypeError("Action must be a callable or an instance of BaseAction")
|
raise TypeError("Action must be a callable or an instance of BaseAction")
|
||||||
|
|
||||||
def get_argument_definitions(self) -> list[dict[str, Any]]:
|
def _get_argument_definitions(self) -> list[dict[str, Any]]:
|
||||||
if self.arguments:
|
if self.arguments:
|
||||||
return self.arguments
|
return self.arguments
|
||||||
elif callable(self.argument_config):
|
elif callable(self.argument_config) and isinstance(
|
||||||
|
self.arg_parser, CommandArgumentParser
|
||||||
|
):
|
||||||
self.argument_config(self.arg_parser)
|
self.argument_config(self.arg_parser)
|
||||||
elif self.auto_args:
|
elif self.auto_args:
|
||||||
if isinstance(self.action, BaseAction):
|
if isinstance(self.action, BaseAction):
|
||||||
@@ -215,8 +354,28 @@ class Command(BaseModel):
|
|||||||
if self.logging_hooks and isinstance(self.action, BaseAction):
|
if self.logging_hooks and isinstance(self.action, BaseAction):
|
||||||
register_debug_hooks(self.action.hooks)
|
register_debug_hooks(self.action.hooks)
|
||||||
|
|
||||||
for arg_def in self.get_argument_definitions():
|
if self.arg_parser is None and not self.custom_parser:
|
||||||
self.arg_parser.add_argument(*arg_def.pop("flags"), **arg_def)
|
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)
|
||||||
|
|
||||||
|
if isinstance(self.arg_parser, CommandArgumentParser) and self.execution_options:
|
||||||
|
self.arg_parser.enable_execution_options(self.execution_options)
|
||||||
|
|
||||||
|
if isinstance(self.arg_parser, CommandArgumentParser):
|
||||||
|
self.arg_parser.set_options_manager(self.options_manager)
|
||||||
|
|
||||||
|
if self.ignore_in_history and isinstance(self.action, BaseAction):
|
||||||
|
self.action.ignore_in_history = True
|
||||||
|
|
||||||
def _inject_options_manager(self) -> None:
|
def _inject_options_manager(self) -> None:
|
||||||
"""Inject the options manager into the action if applicable."""
|
"""Inject the options manager into the action if applicable."""
|
||||||
@@ -224,9 +383,41 @@ class Command(BaseModel):
|
|||||||
self.action.set_options_manager(self.options_manager)
|
self.action.set_options_manager(self.options_manager)
|
||||||
|
|
||||||
async def __call__(self, *args, **kwargs) -> Any:
|
async def __call__(self, *args, **kwargs) -> Any:
|
||||||
"""
|
"""Execute the command's underlying action with lifecycle management.
|
||||||
Run the action with full hook lifecycle, timing, error handling,
|
|
||||||
confirmation prompts, preview, and spinner integration.
|
This method invokes the bound action (BaseAction or callable) using the
|
||||||
|
provided arguments while applying the full Falyx execution lifecycle.
|
||||||
|
|
||||||
|
Execution Flow:
|
||||||
|
1. Create an ExecutionContext for tracking inputs, results, and timing
|
||||||
|
2. Trigger `before` hooks
|
||||||
|
3. Execute the underlying action
|
||||||
|
4. Trigger `on_success` or `on_error` hooks
|
||||||
|
5. Trigger `after` and `on_teardown` hooks
|
||||||
|
6. Record execution via ExecutionRegistry
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- Supports both synchronous and asynchronous actions
|
||||||
|
- Applies retry policies if configured
|
||||||
|
- Integrates with confirmation and execution options via OptionsManager
|
||||||
|
- Propagates exceptions unless recovered by hooks (e.g. retry handlers)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
*args (Any): Positional arguments passed to the action.
|
||||||
|
**kwargs (Any): Keyword arguments passed to the action.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Any: Result returned by the underlying action.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: Propagates execution errors unless handled by hooks.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- This method does not perform argument parsing; inputs are assumed
|
||||||
|
to be pre-processed via `resolve_args`.
|
||||||
|
- Execution options (e.g. retries, confirm) are applied externally
|
||||||
|
via Falyx in OptionsManager before invocation.
|
||||||
|
- Lifecycle hooks are always executed, even in failure cases.
|
||||||
"""
|
"""
|
||||||
self._inject_options_manager()
|
self._inject_options_manager()
|
||||||
combined_args = args + self.args
|
combined_args = args + self.args
|
||||||
@@ -242,24 +433,15 @@ class Command(BaseModel):
|
|||||||
if should_prompt_user(confirm=self.confirm, options=self.options_manager):
|
if should_prompt_user(confirm=self.confirm, options=self.options_manager):
|
||||||
if self.preview_before_confirm:
|
if self.preview_before_confirm:
|
||||||
await self.preview()
|
await self.preview()
|
||||||
if not await confirm_async(self.confirmation_prompt):
|
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.")
|
raise CancelSignal(f"[Command:{self.key}] Cancelled by confirmation.")
|
||||||
|
|
||||||
context.start_timer()
|
context.start_timer()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self.hooks.trigger(HookType.BEFORE, context)
|
await self.hooks.trigger(HookType.BEFORE, context)
|
||||||
if self.spinner:
|
result = await self.action(*combined_args, **combined_kwargs)
|
||||||
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
|
context.result = result
|
||||||
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||||
@@ -280,7 +462,7 @@ class Command(BaseModel):
|
|||||||
return self._context.result if self._context else None
|
return self._context.result if self._context else None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def confirmation_prompt(self) -> FormattedText:
|
def _confirmation_prompt(self) -> FormattedText:
|
||||||
"""Generate a styled prompt_toolkit FormattedText confirmation message."""
|
"""Generate a styled prompt_toolkit FormattedText confirmation message."""
|
||||||
if self.confirm_message and self.confirm_message != "Are you sure?":
|
if self.confirm_message and self.confirm_message != "Are you sure?":
|
||||||
return FormattedText([("class:confirm", self.confirm_message)])
|
return FormattedText([("class:confirm", self.confirm_message)])
|
||||||
@@ -304,11 +486,93 @@ class Command(BaseModel):
|
|||||||
|
|
||||||
return FormattedText(prompt)
|
return FormattedText(prompt)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_alias(self) -> str:
|
||||||
|
"""Get the primary alias for the command, used in help displays."""
|
||||||
|
if self.aliases:
|
||||||
|
return self.aliases[0].lower()
|
||||||
|
return self.key
|
||||||
|
|
||||||
|
@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()
|
||||||
|
options_text = self.arg_parser.get_options_text()
|
||||||
|
return f" {command_keys_text:<20} {options_text} "
|
||||||
|
|
||||||
|
@property
|
||||||
|
def help_signature(
|
||||||
|
self,
|
||||||
|
invocation_context: InvocationContext | None = None,
|
||||||
|
) -> tuple[str, str, str]:
|
||||||
|
"""Return a formatted help signature for display.
|
||||||
|
|
||||||
|
This property provides the core information used to render command help
|
||||||
|
in both CLI and interactive menu modes.
|
||||||
|
|
||||||
|
The signature consists of:
|
||||||
|
- usage: A formatted usage string (including arguments if defined)
|
||||||
|
- description: A short description of the command
|
||||||
|
- tag: Optional tag or category label (if applicable)
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- If a CommandArgumentParser is present, delegates usage generation to
|
||||||
|
the parser (`get_usage()`).
|
||||||
|
- Otherwise, constructs a minimal usage string from the command key.
|
||||||
|
- Honors `simple_help_signature` to produce a condensed representation
|
||||||
|
(e.g. omitting argument details).
|
||||||
|
- Applies styling appropriate for Rich rendering.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple:
|
||||||
|
- str: Usage string (e.g. "falyx D | deploy [--help] region")
|
||||||
|
- str: Command description
|
||||||
|
- str: Optional tag/category label
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- This is the primary interface used by help menus, CLI help output,
|
||||||
|
and command listings.
|
||||||
|
- Formatting may vary depending on CLI vs menu mode.
|
||||||
|
"""
|
||||||
|
if self.arg_parser and not self.simple_help_signature:
|
||||||
|
usage = self.arg_parser.get_usage(invocation_context)
|
||||||
|
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"{command_keys}",
|
||||||
|
f"[dim]{self.help_text or self.description}[/dim]",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
def log_summary(self) -> None:
|
def log_summary(self) -> None:
|
||||||
if self._context:
|
if self._context:
|
||||||
self._context.log_summary()
|
self._context.log_summary()
|
||||||
|
|
||||||
def show_help(self) -> bool:
|
def render_usage(self, invocation_context: InvocationContext | None = None) -> None:
|
||||||
|
"""Render the usage information for the command."""
|
||||||
|
if callable(self.custom_usage):
|
||||||
|
output = self.custom_usage()
|
||||||
|
if output:
|
||||||
|
console.print(output)
|
||||||
|
return
|
||||||
|
if isinstance(self.arg_parser, CommandArgumentParser):
|
||||||
|
self.arg_parser.render_usage(invocation_context)
|
||||||
|
else:
|
||||||
|
console.print(f"[bold]usage:[/] {self.key}")
|
||||||
|
|
||||||
|
def render_help(self, invocation_context: InvocationContext | None = None) -> bool:
|
||||||
"""Display the help message for the command."""
|
"""Display the help message for the command."""
|
||||||
if callable(self.custom_help):
|
if callable(self.custom_help):
|
||||||
output = self.custom_help()
|
output = self.custom_help()
|
||||||
@@ -316,7 +580,19 @@ class Command(BaseModel):
|
|||||||
console.print(output)
|
console.print(output)
|
||||||
return True
|
return True
|
||||||
if isinstance(self.arg_parser, CommandArgumentParser):
|
if isinstance(self.arg_parser, CommandArgumentParser):
|
||||||
self.arg_parser.render_help()
|
self.arg_parser.render_help(invocation_context)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def render_tldr(self, invocation_context: InvocationContext | None = None) -> bool:
|
||||||
|
"""Display the TLDR message for the command."""
|
||||||
|
if callable(self.custom_tldr):
|
||||||
|
output = self.custom_tldr()
|
||||||
|
if output:
|
||||||
|
console.print(output)
|
||||||
|
return True
|
||||||
|
if isinstance(self.arg_parser, CommandArgumentParser):
|
||||||
|
self.arg_parser.render_tldr(invocation_context)
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -350,3 +626,232 @@ class Command(BaseModel):
|
|||||||
f"Command(key='{self.key}', description='{self.description}' "
|
f"Command(key='{self.key}', description='{self.description}' "
|
||||||
f"action='{self.action}')"
|
f"action='{self.action}')"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def build(
|
||||||
|
cls,
|
||||||
|
key: str,
|
||||||
|
description: str,
|
||||||
|
action: BaseAction | Callable[..., Any],
|
||||||
|
*,
|
||||||
|
args: tuple = (),
|
||||||
|
kwargs: dict[str, Any] | None = None,
|
||||||
|
hidden: bool = False,
|
||||||
|
aliases: list[str] | None = None,
|
||||||
|
help_text: str = "",
|
||||||
|
help_epilog: str = "",
|
||||||
|
style: Style | str = OneColors.WHITE,
|
||||||
|
confirm: bool = False,
|
||||||
|
confirm_message: str = "Are you sure?",
|
||||||
|
preview_before_confirm: bool = True,
|
||||||
|
spinner: bool = False,
|
||||||
|
spinner_message: str = "Processing...",
|
||||||
|
spinner_type: str = "dots",
|
||||||
|
spinner_style: Style | str = OneColors.CYAN,
|
||||||
|
spinner_speed: float = 1.0,
|
||||||
|
options_manager: OptionsManager | None = None,
|
||||||
|
hooks: HookManager | None = None,
|
||||||
|
before_hooks: list[Callable] | None = None,
|
||||||
|
success_hooks: list[Callable] | None = None,
|
||||||
|
error_hooks: list[Callable] | None = None,
|
||||||
|
after_hooks: list[Callable] | None = None,
|
||||||
|
teardown_hooks: list[Callable] | None = None,
|
||||||
|
tags: list[str] | None = None,
|
||||||
|
logging_hooks: bool = False,
|
||||||
|
retry: bool = False,
|
||||||
|
retry_all: bool = False,
|
||||||
|
retry_policy: RetryPolicy | None = None,
|
||||||
|
arg_parser: CommandArgumentParser | None = None,
|
||||||
|
arguments: list[dict[str, Any]] | None = None,
|
||||||
|
argument_config: Callable[[CommandArgumentParser], None] | None = None,
|
||||||
|
execution_options: list[ExecutionOption | str] | None = None,
|
||||||
|
custom_parser: ArgParserProtocol | None = None,
|
||||||
|
custom_help: Callable[[], str | None] | None = None,
|
||||||
|
custom_tldr: Callable[[], str | None] | None = None,
|
||||||
|
custom_usage: Callable[[], str | None] | None = None,
|
||||||
|
auto_args: bool = True,
|
||||||
|
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
|
||||||
|
simple_help_signature: bool = False,
|
||||||
|
ignore_in_history: bool = False,
|
||||||
|
program: str | None = None,
|
||||||
|
) -> Command:
|
||||||
|
"""Build and configure a `Command` instance from high-level constructor inputs.
|
||||||
|
|
||||||
|
This factory centralizes command construction so callers such as `Falyx` and
|
||||||
|
`CommandRunner` can create fully configured commands through one consistent
|
||||||
|
path. It normalizes optional inputs, validates selected objects, converts
|
||||||
|
execution options into their canonical internal form, and registers any
|
||||||
|
requested command-level hooks.
|
||||||
|
|
||||||
|
In addition to instantiating the `Command`, this method can:
|
||||||
|
- validate and attach an explicit `CommandArgumentParser`
|
||||||
|
- normalize execution options into a `frozenset[ExecutionOption]`
|
||||||
|
- ensure a shared `OptionsManager` is available
|
||||||
|
- attach a custom `HookManager`
|
||||||
|
- register lifecycle hooks for the command
|
||||||
|
- register spinner hooks when spinner support is enabled
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key (str): Primary identifier used to invoke the command.
|
||||||
|
description (str): Short description of the command.
|
||||||
|
action (BaseAction | Callable[..., Any]): Underlying execution logic for
|
||||||
|
the command.
|
||||||
|
args (tuple): Static positional arguments applied to every execution.
|
||||||
|
kwargs (dict[str, Any] | None): Static keyword arguments applied to every
|
||||||
|
execution.
|
||||||
|
hidden (bool): Whether the command should be hidden from menu displays.
|
||||||
|
aliases (list[str] | None): Optional alternate names for invocation.
|
||||||
|
help_text (str): Help text shown in command help output.
|
||||||
|
help_epilog (str): Additional help text shown after the main help body.
|
||||||
|
style (Style | str): Rich style used when rendering the command.
|
||||||
|
confirm (bool): Whether confirmation is required before execution.
|
||||||
|
confirm_message (str): Confirmation prompt text.
|
||||||
|
preview_before_confirm (bool): Whether to preview before confirmation.
|
||||||
|
spinner (bool): Whether to enable spinner lifecycle hooks.
|
||||||
|
spinner_message (str): Spinner message text.
|
||||||
|
spinner_type (str): Spinner animation type.
|
||||||
|
spinner_style (Style | str): Spinner style.
|
||||||
|
spinner_speed (float): Spinner speed multiplier.
|
||||||
|
options_manager (OptionsManager | None): Shared options manager for the
|
||||||
|
command and its parser.
|
||||||
|
hooks (HookManager | None): Optional hook manager to assign directly to the
|
||||||
|
command.
|
||||||
|
before_hooks (list[Callable] | None): Hooks registered for the `BEFORE`
|
||||||
|
lifecycle stage.
|
||||||
|
success_hooks (list[Callable] | None): Hooks registered for the
|
||||||
|
`ON_SUCCESS` lifecycle stage.
|
||||||
|
error_hooks (list[Callable] | None): Hooks registered for the `ON_ERROR`
|
||||||
|
lifecycle stage.
|
||||||
|
after_hooks (list[Callable] | None): Hooks registered for the `AFTER`
|
||||||
|
lifecycle stage.
|
||||||
|
teardown_hooks (list[Callable] | None): Hooks registered for the
|
||||||
|
`ON_TEARDOWN` lifecycle stage.
|
||||||
|
tags (list[str] | None): Optional tags used for grouping and filtering.
|
||||||
|
logging_hooks (bool): Whether to enable debug hook logging.
|
||||||
|
retry (bool): Whether retry behavior is enabled.
|
||||||
|
retry_all (bool): Whether retry behavior should be applied recursively.
|
||||||
|
retry_policy (RetryPolicy | None): Retry configuration for the command.
|
||||||
|
arg_parser (CommandArgumentParser | None): Optional explicit argument
|
||||||
|
parser instance.
|
||||||
|
arguments (list[dict[str, Any]] | None): Declarative argument
|
||||||
|
definitions for the command parser.
|
||||||
|
argument_config (Callable[[CommandArgumentParser], None] | None): Callback
|
||||||
|
used to configure the argument parser.
|
||||||
|
execution_options (list[ExecutionOption | str] | None): Execution-level
|
||||||
|
options to enable for the command.
|
||||||
|
custom_parser (ArgParserProtocol | None): Optional custom parser
|
||||||
|
implementation that overrides normal parser behavior.
|
||||||
|
custom_help (Callable[[], str | None] | None): Optional custom help
|
||||||
|
renderer.
|
||||||
|
custom_tldr (Callable[[], str | None] | None): Optional custom TLDR
|
||||||
|
renderer.
|
||||||
|
custom_usage (Callable[[], str | None] | None): Optional custom usage
|
||||||
|
renderer.
|
||||||
|
auto_args (bool): Whether to infer arguments automatically from the action
|
||||||
|
signature when explicit definitions are not provided.
|
||||||
|
arg_metadata (dict[str, str | dict[str, Any]] | None): Optional metadata
|
||||||
|
used during argument inference.
|
||||||
|
simple_help_signature (bool): Whether to use a simplified help signature.
|
||||||
|
ignore_in_history (bool): Whether to exclude the command from execution
|
||||||
|
history tracking.
|
||||||
|
program (str | None): Parent program name used in help rendering.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Command: A fully configured `Command` instance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotAFalyxError: If `arg_parser` is provided but is not a
|
||||||
|
`CommandArgumentParser` instance.
|
||||||
|
InvalidHookError: If `hooks` is provided but is not a `HookManager` instance.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Execution options supplied as strings are converted to
|
||||||
|
`ExecutionOption` enum values before the command is created.
|
||||||
|
- If no `options_manager` is provided, a new `OptionsManager` is created.
|
||||||
|
- Spinner hooks are registered at build time when `spinner=True`.
|
||||||
|
- This method is the canonical command-construction path used by higher-
|
||||||
|
level APIs such as `Falyx.add_command()` and `CommandRunner.build()`.
|
||||||
|
"""
|
||||||
|
if arg_parser and not isinstance(arg_parser, CommandArgumentParser):
|
||||||
|
raise NotAFalyxError(
|
||||||
|
"arg_parser must be an instance of CommandArgumentParser."
|
||||||
|
)
|
||||||
|
arg_parser = arg_parser
|
||||||
|
|
||||||
|
if options_manager and not isinstance(options_manager, OptionsManager):
|
||||||
|
raise NotAFalyxError("options_manager must be an instance of OptionsManager.")
|
||||||
|
options_manager = options_manager or OptionsManager()
|
||||||
|
|
||||||
|
if hooks and not isinstance(hooks, HookManager):
|
||||||
|
raise InvalidHookError("hooks must be an instance of HookManager.")
|
||||||
|
hooks = hooks or HookManager()
|
||||||
|
|
||||||
|
if retry_policy and not isinstance(retry_policy, RetryPolicy):
|
||||||
|
raise NotAFalyxError("retry_policy must be an instance of RetryPolicy.")
|
||||||
|
retry_policy = retry_policy or RetryPolicy()
|
||||||
|
|
||||||
|
if execution_options:
|
||||||
|
parsed_execution_options = frozenset(
|
||||||
|
ExecutionOption(option) if isinstance(option, str) else option
|
||||||
|
for option in execution_options
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
parsed_execution_options = frozenset()
|
||||||
|
|
||||||
|
command = Command(
|
||||||
|
key=key,
|
||||||
|
description=description,
|
||||||
|
action=action,
|
||||||
|
args=args,
|
||||||
|
kwargs=kwargs if kwargs else {},
|
||||||
|
hidden=hidden,
|
||||||
|
aliases=aliases if aliases else [],
|
||||||
|
help_text=help_text,
|
||||||
|
help_epilog=help_epilog,
|
||||||
|
style=style,
|
||||||
|
confirm=confirm,
|
||||||
|
confirm_message=confirm_message,
|
||||||
|
preview_before_confirm=preview_before_confirm,
|
||||||
|
spinner=spinner,
|
||||||
|
spinner_message=spinner_message,
|
||||||
|
spinner_type=spinner_type,
|
||||||
|
spinner_style=spinner_style,
|
||||||
|
spinner_speed=spinner_speed,
|
||||||
|
tags=tags if tags else [],
|
||||||
|
logging_hooks=logging_hooks,
|
||||||
|
hooks=hooks,
|
||||||
|
retry=retry,
|
||||||
|
retry_all=retry_all,
|
||||||
|
retry_policy=retry_policy,
|
||||||
|
options_manager=options_manager,
|
||||||
|
arg_parser=arg_parser,
|
||||||
|
execution_options=parsed_execution_options,
|
||||||
|
arguments=arguments or [],
|
||||||
|
argument_config=argument_config,
|
||||||
|
custom_parser=custom_parser,
|
||||||
|
custom_help=custom_help,
|
||||||
|
custom_tldr=custom_tldr,
|
||||||
|
custom_usage=custom_usage,
|
||||||
|
auto_args=auto_args,
|
||||||
|
arg_metadata=arg_metadata or {},
|
||||||
|
simple_help_signature=simple_help_signature,
|
||||||
|
ignore_in_history=ignore_in_history,
|
||||||
|
program=program,
|
||||||
|
)
|
||||||
|
|
||||||
|
for hook in before_hooks or []:
|
||||||
|
command.hooks.register(HookType.BEFORE, hook)
|
||||||
|
for hook in success_hooks or []:
|
||||||
|
command.hooks.register(HookType.ON_SUCCESS, hook)
|
||||||
|
for hook in error_hooks or []:
|
||||||
|
command.hooks.register(HookType.ON_ERROR, hook)
|
||||||
|
for hook in after_hooks or []:
|
||||||
|
command.hooks.register(HookType.AFTER, hook)
|
||||||
|
for hook in teardown_hooks or []:
|
||||||
|
command.hooks.register(HookType.ON_TEARDOWN, hook)
|
||||||
|
|
||||||
|
if spinner:
|
||||||
|
command.hooks.register(HookType.BEFORE, spinner_before_hook)
|
||||||
|
command.hooks.register(HookType.ON_TEARDOWN, spinner_teardown_hook)
|
||||||
|
|
||||||
|
return command
|
||||||
|
|||||||
311
falyx/command_executor.py
Normal file
311
falyx/command_executor.py
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
|
"""Shared command execution engine for the Falyx CLI framework.
|
||||||
|
|
||||||
|
This module defines `CommandExecutor`, a low-level execution service responsible
|
||||||
|
for running already-resolved `Command` objects with a consistent outer lifecycle.
|
||||||
|
|
||||||
|
`CommandExecutor` sits between higher-level orchestration layers (such as
|
||||||
|
`Falyx.execute_command()` or `CommandRunner.run()`) and the command itself.
|
||||||
|
It does not perform command lookup or argument parsing. Instead, it accepts a
|
||||||
|
resolved `Command` plus prepared `args`, `kwargs`, and `execution_args`, then
|
||||||
|
applies executor-level behavior around the command invocation.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
- Apply execution-scoped runtime overrides such as confirmation flags
|
||||||
|
- Apply retry overrides from execution arguments
|
||||||
|
- Trigger executor-level lifecycle hooks
|
||||||
|
- Create and manage an outer `ExecutionContext`
|
||||||
|
- Delegate actual invocation to the resolved `Command`
|
||||||
|
- Handle interruption and failure policies
|
||||||
|
- Optionally print execution summaries via `ExecutionRegistry`
|
||||||
|
|
||||||
|
Execution Model:
|
||||||
|
1. A command is resolved and its arguments are prepared elsewhere.
|
||||||
|
2. Retry and execution-option overrides are derived from `execution_args`.
|
||||||
|
3. An outer `ExecutionContext` is created for executor-level tracking.
|
||||||
|
4. Executor hooks are triggered around the command invocation.
|
||||||
|
5. The command is executed inside an `OptionsManager.override_namespace()`
|
||||||
|
context for scoped runtime overrides.
|
||||||
|
6. Errors are either surfaced, wrapped, or rendered depending on the
|
||||||
|
configured execution policy.
|
||||||
|
7. Optional summary output is emitted after execution completes.
|
||||||
|
|
||||||
|
Design Notes:
|
||||||
|
- `CommandExecutor` is intentionally narrower in scope than `Falyx`.
|
||||||
|
It does not resolve commands, parse raw CLI text, or manage menu state.
|
||||||
|
- `Command` still owns command-local behavior such as confirmation,
|
||||||
|
command hooks, and delegation to the underlying `Action`.
|
||||||
|
- This module exists to centralize shared execution behavior and reduce
|
||||||
|
duplication across Falyx runtime entrypoints.
|
||||||
|
|
||||||
|
Typical Usage:
|
||||||
|
executor = CommandExecutor(options=options, hooks=hooks)
|
||||||
|
result = await executor.execute(
|
||||||
|
command=command,
|
||||||
|
args=args,
|
||||||
|
kwargs=kwargs,
|
||||||
|
execution_args=execution_args,
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from falyx.action import Action
|
||||||
|
from falyx.command import Command
|
||||||
|
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.options_manager import OptionsManager
|
||||||
|
|
||||||
|
|
||||||
|
class CommandExecutor:
|
||||||
|
"""Execute resolved Falyx commands with shared outer lifecycle handling.
|
||||||
|
|
||||||
|
`CommandExecutor` provides a reusable execution service for running a
|
||||||
|
`Command` after command resolution and argument parsing have already been
|
||||||
|
completed.
|
||||||
|
|
||||||
|
This class is intended to be shared by higher-level entrypoints such as
|
||||||
|
`Falyx` and `CommandRunner`. It centralizes the outer execution flow so
|
||||||
|
command execution semantics remain consistent across menu-driven and
|
||||||
|
programmatic use cases.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
- Apply retry overrides from execution arguments
|
||||||
|
- Apply scoped runtime overrides using `OptionsManager`
|
||||||
|
- Trigger executor-level hooks before and after command execution
|
||||||
|
- Create and manage an executor-level `ExecutionContext`
|
||||||
|
- Control whether errors are raised or wrapped
|
||||||
|
- Emit optional execution summaries
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
options (OptionsManager): Shared options manager used to apply scoped
|
||||||
|
execution overrides.
|
||||||
|
hooks (HookManager): Hook manager for executor-level lifecycle hooks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
options: OptionsManager,
|
||||||
|
hooks: HookManager,
|
||||||
|
) -> None:
|
||||||
|
self.options = options
|
||||||
|
self.hooks = hooks
|
||||||
|
|
||||||
|
def _debug_hooks(self, command: Command) -> None:
|
||||||
|
"""Log executor-level and command-level hook registrations for debugging.
|
||||||
|
|
||||||
|
This helper is used to surface the currently registered hooks on both the
|
||||||
|
executor and the resolved command before execution begins.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command (Command): The command about to be executed.
|
||||||
|
"""
|
||||||
|
logger.debug("executor hooks:\n%s", str(self.hooks))
|
||||||
|
logger.debug("['%s'] hooks:\n%s", command.key, str(command.hooks))
|
||||||
|
|
||||||
|
def _apply_retry_overrides(
|
||||||
|
self,
|
||||||
|
command: Command,
|
||||||
|
execution_args: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Apply retry-related execution overrides to the command.
|
||||||
|
|
||||||
|
This method inspects execution-level retry options and updates the
|
||||||
|
command's retry policy in place when overrides are provided. If the
|
||||||
|
command's underlying action is an `Action`, the updated retry policy is
|
||||||
|
propagated to that action as well.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command (Command): The command whose retry policy may be updated.
|
||||||
|
execution_args (dict[str, Any]): Execution-level arguments that may
|
||||||
|
contain retry overrides such as `retries`, `retry_delay`, and
|
||||||
|
`retry_backoff`.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- If no retry-related overrides are provided, this method does nothing.
|
||||||
|
- If the command action is not an `Action`, a warning is logged and the
|
||||||
|
command-level retry policy is updated without propagating it further.
|
||||||
|
"""
|
||||||
|
retries = execution_args.get("retries", 0)
|
||||||
|
retry_delay = execution_args.get("retry_delay", 0.0)
|
||||||
|
retry_backoff = execution_args.get("retry_backoff", 0.0)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"[_apply_retry_overrides]: retries=%s, retry_delay=%s, retry_backoff=%s",
|
||||||
|
retries,
|
||||||
|
retry_delay,
|
||||||
|
retry_backoff,
|
||||||
|
)
|
||||||
|
if not retries and not retry_delay and not retry_backoff:
|
||||||
|
return
|
||||||
|
|
||||||
|
command.retry_policy.enabled = True
|
||||||
|
if retries:
|
||||||
|
command.retry_policy.max_retries = retries
|
||||||
|
if retry_delay:
|
||||||
|
command.retry_policy.delay = retry_delay
|
||||||
|
if retry_backoff:
|
||||||
|
command.retry_policy.backoff = retry_backoff
|
||||||
|
|
||||||
|
if isinstance(command.action, Action):
|
||||||
|
command.action.set_retry_policy(command.retry_policy)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"[%s] Retry requested, but action is not an Action instance.",
|
||||||
|
command.key,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execution_option_overrides(
|
||||||
|
self,
|
||||||
|
execution_args: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build scoped option overrides from execution arguments.
|
||||||
|
|
||||||
|
This method extracts execution-only runtime flags that should be applied
|
||||||
|
through the `OptionsManager` during command execution.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
execution_args (dict[str, Any]): Execution-level arguments returned
|
||||||
|
from command argument resolution.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Any]: Mapping of option names to temporary execution-scoped
|
||||||
|
override values.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"force_confirm": execution_args.get("force_confirm", False),
|
||||||
|
"skip_confirm": execution_args.get("skip_confirm", False),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
command: Command,
|
||||||
|
args: tuple,
|
||||||
|
kwargs: dict[str, Any],
|
||||||
|
execution_args: dict[str, Any],
|
||||||
|
raise_on_error: bool = True,
|
||||||
|
wrap_errors: bool = False,
|
||||||
|
summary_last_result: bool = False,
|
||||||
|
) -> Any:
|
||||||
|
"""Execute a resolved command with executor-level lifecycle management.
|
||||||
|
|
||||||
|
This method is the primary entrypoint of `CommandExecutor`. It accepts an
|
||||||
|
already-resolved `Command` and its prepared execution inputs, then applies
|
||||||
|
shared outer execution behavior around the command invocation.
|
||||||
|
|
||||||
|
Execution Flow:
|
||||||
|
1. Log currently registered hooks for debugging.
|
||||||
|
2. Apply retry overrides from `execution_args`.
|
||||||
|
3. Derive scoped runtime overrides for the execution namespace.
|
||||||
|
4. Create and start an outer `ExecutionContext`.
|
||||||
|
5. Trigger executor-level `BEFORE` hooks.
|
||||||
|
6. Execute the command inside an execution-scoped options override
|
||||||
|
context.
|
||||||
|
7. Trigger executor-level `SUCCESS` or `ERROR` hooks.
|
||||||
|
8. Trigger `AFTER` and `ON_TEARDOWN` hooks.
|
||||||
|
9. Optionally print an execution summary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command (Command): The resolved command to execute.
|
||||||
|
args (tuple): Positional arguments to pass to the command.
|
||||||
|
kwargs (dict[str, Any]): Keyword arguments to pass to the command.
|
||||||
|
execution_args (dict[str, Any]): Execution-only arguments that affect
|
||||||
|
runtime behavior, such as retry or confirmation overrides.
|
||||||
|
raise_on_error (bool): Whether execution errors should be re-raised
|
||||||
|
after handling.
|
||||||
|
wrap_errors (bool): Whether handled errors should be wrapped in a
|
||||||
|
`FalyxError` before being raised.
|
||||||
|
summary_last_result (bool): Whether summary output should only have the
|
||||||
|
last recorded result when summary reporting is enabled.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Any: The result returned by the command, or any recovered result
|
||||||
|
attached to the execution context.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyboardInterrupt: If execution is interrupted by the user and
|
||||||
|
`raise_on_error` is True and `wrap_errors` is False.
|
||||||
|
EOFError: If execution receives EOF interruption and `raise_on_error`
|
||||||
|
is True and `wrap_errors` is False.
|
||||||
|
FalyxError: If `wrap_errors` is True and execution is interrupted or
|
||||||
|
fails.
|
||||||
|
Exception: Re-raises the underlying execution error when
|
||||||
|
`raise_on_error` is True and `wrap_errors` is False.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- This method assumes the command has already been resolved and its
|
||||||
|
arguments have already been parsed.
|
||||||
|
- Command-local behavior, such as confirmation prompts and command hook
|
||||||
|
execution, remains the responsibility of `Command.__call__()`.
|
||||||
|
- Summary output is only emitted when the `summary` execution option is
|
||||||
|
present in `execution_args`.
|
||||||
|
"""
|
||||||
|
if not (raise_on_error or wrap_errors):
|
||||||
|
raise FalyxError(
|
||||||
|
"CommandExecutor.execute() requires either raise_on_error=True "
|
||||||
|
"or wrap_errors=True."
|
||||||
|
)
|
||||||
|
self._debug_hooks(command)
|
||||||
|
self._apply_retry_overrides(command, execution_args)
|
||||||
|
overrides = self._execution_option_overrides(execution_args)
|
||||||
|
|
||||||
|
context = ExecutionContext(
|
||||||
|
name=command.description,
|
||||||
|
args=args,
|
||||||
|
kwargs=kwargs,
|
||||||
|
action=command,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"[execute] Starting execution of '%s' with args: %s, kwargs: %s",
|
||||||
|
command.description,
|
||||||
|
args,
|
||||||
|
kwargs,
|
||||||
|
)
|
||||||
|
context.start_timer()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.hooks.trigger(HookType.BEFORE, context)
|
||||||
|
with self.options.override_namespace(
|
||||||
|
overrides=overrides,
|
||||||
|
namespace_name="execution",
|
||||||
|
):
|
||||||
|
result = await command(*args, **kwargs)
|
||||||
|
context.result = result
|
||||||
|
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||||
|
except (KeyboardInterrupt, EOFError) as error:
|
||||||
|
logger.info(
|
||||||
|
"[execute] '%s' interrupted by user.",
|
||||||
|
command.key,
|
||||||
|
)
|
||||||
|
if wrap_errors:
|
||||||
|
raise FalyxError(
|
||||||
|
f"[execute] '{command.key}' interrupted by user."
|
||||||
|
) from error
|
||||||
|
raise error
|
||||||
|
except Exception as error:
|
||||||
|
logger.debug(
|
||||||
|
"[execute] '%s' failed: %s",
|
||||||
|
command.key,
|
||||||
|
error,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
context.exception = error
|
||||||
|
await self.hooks.trigger(HookType.ON_ERROR, context)
|
||||||
|
if wrap_errors:
|
||||||
|
raise FalyxError(f"[execute] '{command.key}' failed: {error}") from error
|
||||||
|
raise error
|
||||||
|
finally:
|
||||||
|
context.stop_timer()
|
||||||
|
await self.hooks.trigger(HookType.AFTER, context)
|
||||||
|
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
||||||
|
if execution_args.get("summary") and summary_last_result:
|
||||||
|
er.summary(last_result=True)
|
||||||
|
elif execution_args.get("summary"):
|
||||||
|
er.summary()
|
||||||
|
return context.result
|
||||||
531
falyx/command_runner.py
Normal file
531
falyx/command_runner.py
Normal file
@@ -0,0 +1,531 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
|
"""Standalone command runner for the Falyx CLI framework.
|
||||||
|
|
||||||
|
This module defines `CommandRunner`, a developer-facing convenience wrapper for
|
||||||
|
executing a single `Command` outside the full `Falyx` runtime.
|
||||||
|
|
||||||
|
`CommandRunner` is designed for programmatic and standalone command execution
|
||||||
|
where command lookup, menu interaction, and root CLI parsing are not needed.
|
||||||
|
It provides a small, focused API that:
|
||||||
|
|
||||||
|
- owns a single `Command`
|
||||||
|
- ensures the command and parser share a consistent `OptionsManager`
|
||||||
|
- delegates shared execution behavior to `CommandExecutor`
|
||||||
|
- supports both wrapping an existing `Command` and building one from raw
|
||||||
|
constructor-style arguments
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
- Hold a single resolved `Command` for repeated execution
|
||||||
|
- Normalize runtime dependencies such as `OptionsManager`, `HookManager`,
|
||||||
|
and `Console`
|
||||||
|
- Resolve command arguments from raw argv-style input
|
||||||
|
- Delegate execution to `CommandExecutor` for shared outer lifecycle
|
||||||
|
handling
|
||||||
|
|
||||||
|
Design Notes:
|
||||||
|
- `CommandRunner` is intentionally narrower than `Falyx`.
|
||||||
|
It does not resolve commands by name, render menus, or manage built-ins.
|
||||||
|
- `CommandExecutor` remains the shared execution core.
|
||||||
|
`CommandRunner` exists as a convenience layer for developer-facing and
|
||||||
|
standalone use cases.
|
||||||
|
- `Command` still owns command-local behavior such as confirmation,
|
||||||
|
command hook execution, and delegation to the underlying `Action`.
|
||||||
|
|
||||||
|
Typical Usage:
|
||||||
|
runner = CommandRunner.from_command(existing_command)
|
||||||
|
result = await runner.run(["--region", "us-east"])
|
||||||
|
|
||||||
|
#!/usr/bin/env python
|
||||||
|
import asyncio
|
||||||
|
runner = CommandRunner.build(
|
||||||
|
key="D",
|
||||||
|
description="Deploy",
|
||||||
|
action=deploy,
|
||||||
|
)
|
||||||
|
result = asyncio.run(runner.cli())
|
||||||
|
$ ./deploy.py --region us-east
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from falyx.action import BaseAction
|
||||||
|
from falyx.command import Command
|
||||||
|
from falyx.command_executor import CommandExecutor
|
||||||
|
from falyx.console import console as falyx_console
|
||||||
|
from falyx.console import error_console, print_error
|
||||||
|
from falyx.exceptions import (
|
||||||
|
CommandArgumentError,
|
||||||
|
FalyxError,
|
||||||
|
InvalidHookError,
|
||||||
|
NotAFalyxError,
|
||||||
|
)
|
||||||
|
from falyx.execution_option import ExecutionOption
|
||||||
|
from falyx.hook_manager import HookManager
|
||||||
|
from falyx.logger import logger
|
||||||
|
from falyx.options_manager import OptionsManager
|
||||||
|
from falyx.parser.command_argument_parser import CommandArgumentParser
|
||||||
|
from falyx.protocols import ArgParserProtocol
|
||||||
|
from falyx.retry import RetryPolicy
|
||||||
|
from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
|
||||||
|
from falyx.themes import OneColors
|
||||||
|
|
||||||
|
|
||||||
|
class CommandRunner:
|
||||||
|
"""Run a single Falyx command outside the full Falyx application runtime.
|
||||||
|
|
||||||
|
`CommandRunner` is a lightweight wrapper around a single `Command` plus a
|
||||||
|
`CommandExecutor`. It is intended for standalone execution, testing, and
|
||||||
|
developer-facing programmatic usage where command resolution has already
|
||||||
|
happened or is unnecessary.
|
||||||
|
|
||||||
|
This class is responsible for:
|
||||||
|
- storing the bound `Command`
|
||||||
|
- providing a shared `OptionsManager` to the command and its parser
|
||||||
|
- exposing a simple `run()` method that accepts argv-style input
|
||||||
|
- delegating shared execution behavior to `CommandExecutor`
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
command (Command): The command executed by this runner.
|
||||||
|
program (str): Program name used in CLI usage text and help output.
|
||||||
|
options (OptionsManager): Shared options manager used by the command,
|
||||||
|
parser, and executor.
|
||||||
|
runner_hooks (HookManager): Executor-level hooks used during execution.
|
||||||
|
console (Console): Rich console used for user-facing output.
|
||||||
|
executor (CommandExecutor): Shared execution engine used to run the
|
||||||
|
bound command.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
command: Command,
|
||||||
|
*,
|
||||||
|
program: str | None = None,
|
||||||
|
options: OptionsManager | None = None,
|
||||||
|
runner_hooks: HookManager | None = None,
|
||||||
|
console: Console | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize a `CommandRunner` for a single command.
|
||||||
|
|
||||||
|
The runner ensures that the bound command, its argument parser, and the
|
||||||
|
internal `CommandExecutor` all share the same `OptionsManager` and runtime
|
||||||
|
dependencies.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command (Command): The command to execute.
|
||||||
|
program (str | None): Program name used in CLI usage text, invocation-path
|
||||||
|
rendering, and built-in help output. If `None`, an empty program name is
|
||||||
|
used.
|
||||||
|
options (OptionsManager | None): Optional shared options manager. If
|
||||||
|
omitted, a new `OptionsManager` is created.
|
||||||
|
runner_hooks (HookManager | None): Optional executor-level hook manager. If
|
||||||
|
omitted, a new `HookManager` is created.
|
||||||
|
console (Console | None): Optional Rich console for output. If omitted,
|
||||||
|
the default Falyx console is used.
|
||||||
|
"""
|
||||||
|
self.command = command
|
||||||
|
self.program = program or ""
|
||||||
|
self.options = self._get_options(options)
|
||||||
|
self.runner_hooks = self._get_hooks(runner_hooks)
|
||||||
|
self.console = self._get_console(console)
|
||||||
|
self.error_console = error_console
|
||||||
|
self.command.options_manager = self.options
|
||||||
|
if program:
|
||||||
|
self.command.program = program
|
||||||
|
if isinstance(self.command.arg_parser, CommandArgumentParser):
|
||||||
|
self.command.arg_parser.set_options_manager(self.options)
|
||||||
|
self.command.arg_parser.is_runner_mode = True
|
||||||
|
if program:
|
||||||
|
self.command.arg_parser.program = program
|
||||||
|
self.executor = CommandExecutor(
|
||||||
|
options=self.options,
|
||||||
|
hooks=self.runner_hooks,
|
||||||
|
)
|
||||||
|
self.options.from_mapping(values={}, namespace_name="execution")
|
||||||
|
|
||||||
|
def _get_console(self, console) -> Console:
|
||||||
|
if console is None:
|
||||||
|
return falyx_console
|
||||||
|
elif isinstance(console, Console):
|
||||||
|
return console
|
||||||
|
else:
|
||||||
|
raise NotAFalyxError("console must be an instance of rich.Console or None.")
|
||||||
|
|
||||||
|
def _get_options(self, options) -> OptionsManager:
|
||||||
|
if options is None:
|
||||||
|
return OptionsManager()
|
||||||
|
elif isinstance(options, OptionsManager):
|
||||||
|
return options
|
||||||
|
else:
|
||||||
|
raise NotAFalyxError("options must be an instance of OptionsManager or None.")
|
||||||
|
|
||||||
|
def _get_hooks(self, hooks) -> HookManager:
|
||||||
|
if hooks is None:
|
||||||
|
return HookManager()
|
||||||
|
elif isinstance(hooks, HookManager):
|
||||||
|
return hooks
|
||||||
|
else:
|
||||||
|
raise InvalidHookError("hooks must be an instance of HookManager or None.")
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
self,
|
||||||
|
argv: list[str] | str | None = None,
|
||||||
|
raise_on_error: bool = True,
|
||||||
|
wrap_errors: bool = False,
|
||||||
|
summary_last_result: bool = False,
|
||||||
|
) -> Any:
|
||||||
|
"""Resolve arguments and execute the bound command.
|
||||||
|
|
||||||
|
This method is the primary execution entrypoint for `CommandRunner`. It
|
||||||
|
accepts raw argv-style tokens, resolves them into positional arguments,
|
||||||
|
keyword arguments, and execution arguments via `Command.resolve_args()`,
|
||||||
|
then delegates execution to the internal `CommandExecutor`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
argv (list[str] | str | None): Optional argv-style argument tokens or
|
||||||
|
string (uses `shlex.split()` if a string is provided). If omitted,
|
||||||
|
`sys.argv[1:]` is used.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Any: The result returned by the bound command.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: Propagates any execution error surfaced by the underlying
|
||||||
|
`CommandExecutor` or command execution path.
|
||||||
|
"""
|
||||||
|
argv = sys.argv[1:] if argv is None else argv
|
||||||
|
args, kwargs, execution_args = await self.command.resolve_args(argv)
|
||||||
|
logger.debug(
|
||||||
|
"Executing command '%s' with args=%s, kwargs=%s, execution_args=%s",
|
||||||
|
self.command.description,
|
||||||
|
args,
|
||||||
|
kwargs,
|
||||||
|
execution_args,
|
||||||
|
)
|
||||||
|
return await self.executor.execute(
|
||||||
|
command=self.command,
|
||||||
|
args=args,
|
||||||
|
kwargs=kwargs,
|
||||||
|
execution_args=execution_args,
|
||||||
|
raise_on_error=raise_on_error,
|
||||||
|
wrap_errors=wrap_errors,
|
||||||
|
summary_last_result=summary_last_result,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def cli(
|
||||||
|
self,
|
||||||
|
argv: list[str] | str | None = None,
|
||||||
|
summary_last_result: bool = False,
|
||||||
|
) -> Any:
|
||||||
|
"""Run the bound command as a shell-oriented CLI entrypoint.
|
||||||
|
|
||||||
|
This method wraps `run()` with command-line specific behavior. It executes the
|
||||||
|
bound command using raw argv-style input, then translates framework signals and
|
||||||
|
execution failures into user-facing console output and process exit codes.
|
||||||
|
|
||||||
|
Unlike `run()`, this method is intended for direct CLI usage rather than
|
||||||
|
programmatic integration. It may terminate the current process via `sys.exit()`.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- Delegates normal execution to `run()`
|
||||||
|
- Exits with status code `0` when help output is requested
|
||||||
|
- Exits with status code `2` for command argument or usage errors
|
||||||
|
- Exits with status code `1` for execution failures and non-success control
|
||||||
|
flow such as cancellation or back-navigation
|
||||||
|
- Exits with status code `130` for quit/interrupt-style termination
|
||||||
|
|
||||||
|
Args:
|
||||||
|
argv (list[str] | str | None): Optional argv-style argument tokens or string
|
||||||
|
(uses `shlex.split()` if a string is provided). If omitted, `sys.argv[1:]`
|
||||||
|
is used by `run()`.
|
||||||
|
summary_last_result (bool): Whether summary output should include the last
|
||||||
|
recorded result when summary reporting is enabled.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Any: The result returned by the bound command when execution completes
|
||||||
|
successfully.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SystemExit: Always raised for handled CLI exit paths, including help,
|
||||||
|
argument errors, cancellations, and execution failures.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- This method is intentionally shell-facing and should be used in
|
||||||
|
script entrypoints such as `asyncio.run(runner.cli())`.
|
||||||
|
- For programmatic use, prefer `run()`, which preserves normal Python
|
||||||
|
exception behavior and does not call `sys.exit()`.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await self.run(
|
||||||
|
argv=argv,
|
||||||
|
raise_on_error=False,
|
||||||
|
wrap_errors=True,
|
||||||
|
summary_last_result=summary_last_result,
|
||||||
|
)
|
||||||
|
except HelpSignal:
|
||||||
|
sys.exit(0)
|
||||||
|
except CommandArgumentError as error:
|
||||||
|
self.command.render_help()
|
||||||
|
print_error(message=error)
|
||||||
|
sys.exit(2)
|
||||||
|
except FalyxError as error:
|
||||||
|
print_error(message=error)
|
||||||
|
sys.exit(1)
|
||||||
|
except QuitSignal:
|
||||||
|
logger.info("[QuitSignal]. <- Exiting run.")
|
||||||
|
sys.exit(130)
|
||||||
|
except BackSignal:
|
||||||
|
logger.info("[BackSignal]. <- Exiting run.")
|
||||||
|
sys.exit(1)
|
||||||
|
except CancelSignal:
|
||||||
|
logger.info("[CancelSignal]. <- Exiting run.")
|
||||||
|
sys.exit(1)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("[asyncio.CancelledError]. <- Exiting run.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_command(
|
||||||
|
cls,
|
||||||
|
command: Command,
|
||||||
|
*,
|
||||||
|
program: str | None = None,
|
||||||
|
runner_hooks: HookManager | None = None,
|
||||||
|
options: OptionsManager | None = None,
|
||||||
|
console: Console | None = None,
|
||||||
|
) -> CommandRunner:
|
||||||
|
"""Create a `CommandRunner` from an existing `Command` instance.
|
||||||
|
|
||||||
|
This factory is useful when a command has already been defined elsewhere
|
||||||
|
and should be exposed through the standalone runner interface without
|
||||||
|
rebuilding it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command (Command): Existing command instance to wrap.
|
||||||
|
program (str | None): Program name used in CLI usage text, invocation-path
|
||||||
|
rendering, and built-in help output. If `None`, an empty program name is
|
||||||
|
used.
|
||||||
|
runner_hooks (HookManager | None): Optional executor-level hook manager
|
||||||
|
for the runner.
|
||||||
|
options (OptionsManager | None): Optional shared options manager.
|
||||||
|
console (Console | None): Optional Rich console for output.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandRunner: A runner bound to the provided command.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotAFalyxError: If `runner_hooks` is provided but is not a
|
||||||
|
`HookManager` instance.
|
||||||
|
"""
|
||||||
|
if not isinstance(command, Command):
|
||||||
|
raise NotAFalyxError("command must be an instance of Command.")
|
||||||
|
if runner_hooks and not isinstance(runner_hooks, HookManager):
|
||||||
|
raise InvalidHookError("runner_hooks must be an instance of HookManager.")
|
||||||
|
return cls(
|
||||||
|
command=command,
|
||||||
|
program=program,
|
||||||
|
options=options,
|
||||||
|
runner_hooks=runner_hooks,
|
||||||
|
console=console,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def build(
|
||||||
|
cls,
|
||||||
|
key: str,
|
||||||
|
description: str,
|
||||||
|
action: BaseAction | Callable[..., Any],
|
||||||
|
*,
|
||||||
|
program: str | None = None,
|
||||||
|
runner_hooks: HookManager | None = None,
|
||||||
|
args: tuple = (),
|
||||||
|
kwargs: dict[str, Any] | None = None,
|
||||||
|
hidden: bool = False,
|
||||||
|
aliases: list[str] | None = None,
|
||||||
|
help_text: str = "",
|
||||||
|
help_epilog: str = "",
|
||||||
|
style: str = OneColors.WHITE,
|
||||||
|
confirm: bool = False,
|
||||||
|
confirm_message: str = "Are you sure?",
|
||||||
|
preview_before_confirm: bool = True,
|
||||||
|
spinner: bool = False,
|
||||||
|
spinner_message: str = "Processing...",
|
||||||
|
spinner_type: str = "dots",
|
||||||
|
spinner_style: str = OneColors.CYAN,
|
||||||
|
spinner_speed: float = 1.0,
|
||||||
|
options: OptionsManager | None = None,
|
||||||
|
command_hooks: HookManager | None = None,
|
||||||
|
before_hooks: list[Callable] | None = None,
|
||||||
|
success_hooks: list[Callable] | None = None,
|
||||||
|
error_hooks: list[Callable] | None = None,
|
||||||
|
after_hooks: list[Callable] | None = None,
|
||||||
|
teardown_hooks: list[Callable] | None = None,
|
||||||
|
tags: list[str] | None = None,
|
||||||
|
logging_hooks: bool = False,
|
||||||
|
retry: bool = False,
|
||||||
|
retry_all: bool = False,
|
||||||
|
retry_policy: RetryPolicy | None = None,
|
||||||
|
arg_parser: CommandArgumentParser | None = None,
|
||||||
|
arguments: list[dict[str, Any]] | None = None,
|
||||||
|
argument_config: Callable[[CommandArgumentParser], None] | None = None,
|
||||||
|
execution_options: list[ExecutionOption | str] | None = None,
|
||||||
|
custom_parser: ArgParserProtocol | None = None,
|
||||||
|
custom_help: Callable[[], str | None] | None = None,
|
||||||
|
custom_tldr: Callable[[], str | None] | None = None,
|
||||||
|
custom_usage: Callable[[], str | None] | None = None,
|
||||||
|
auto_args: bool = True,
|
||||||
|
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
|
||||||
|
simple_help_signature: bool = False,
|
||||||
|
ignore_in_history: bool = False,
|
||||||
|
console: Console | None = None,
|
||||||
|
) -> CommandRunner:
|
||||||
|
"""Build a `Command` and wrap it in a `CommandRunner`.
|
||||||
|
|
||||||
|
This factory is a convenience constructor for standalone usage. It mirrors
|
||||||
|
the high-level command-building API by creating a configured `Command`
|
||||||
|
through `Command.build()` and then returning a `CommandRunner` bound to it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key (str): Primary key used to invoke the command.
|
||||||
|
description (str): Short description of the command.
|
||||||
|
action (BaseAction | Callable[..., Any]): Underlying execution logic for
|
||||||
|
the command.
|
||||||
|
program (str | None): Program name used in CLI usage text, invocation-path
|
||||||
|
rendering, and built-in help output. If `None`, an empty program name is
|
||||||
|
used.
|
||||||
|
runner_hooks (HookManager | None): Optional executor-level hooks for the
|
||||||
|
runner.
|
||||||
|
args (tuple): Static positional arguments applied to the command.
|
||||||
|
kwargs (dict[str, Any] | None): Static keyword arguments applied to the
|
||||||
|
command.
|
||||||
|
hidden (bool): Whether the command should be hidden from menu displays.
|
||||||
|
aliases (list[str] | None): Optional alternate invocation names.
|
||||||
|
help_text (str): Help text shown in command help output.
|
||||||
|
help_epilog (str): Additional help text shown after the main help body.
|
||||||
|
style (str): Rich style used for rendering the command.
|
||||||
|
confirm (bool): Whether confirmation is required before execution.
|
||||||
|
confirm_message (str): Confirmation prompt text.
|
||||||
|
preview_before_confirm (bool): Whether to preview before confirmation.
|
||||||
|
spinner (bool): Whether to enable spinner integration.
|
||||||
|
spinner_message (str): Spinner message text.
|
||||||
|
spinner_type (str): Spinner animation type.
|
||||||
|
spinner_style (str): Spinner style.
|
||||||
|
spinner_speed (float): Spinner speed multiplier.
|
||||||
|
options (OptionsManager | None): Shared options manager for the command
|
||||||
|
and runner.
|
||||||
|
command_hooks (HookManager | None): Optional hook manager for the built
|
||||||
|
command itself.
|
||||||
|
before_hooks (list[Callable] | None): Command hooks registered for the
|
||||||
|
`BEFORE` lifecycle stage.
|
||||||
|
success_hooks (list[Callable] | None): Command hooks registered for the
|
||||||
|
`ON_SUCCESS` lifecycle stage.
|
||||||
|
error_hooks (list[Callable] | None): Command hooks registered for the
|
||||||
|
`ON_ERROR` lifecycle stage.
|
||||||
|
after_hooks (list[Callable] | None): Command hooks registered for the
|
||||||
|
`AFTER` lifecycle stage.
|
||||||
|
teardown_hooks (list[Callable] | None): Command hooks registered for the
|
||||||
|
`ON_TEARDOWN` lifecycle stage.
|
||||||
|
tags (list[str] | None): Optional tags used for grouping and filtering.
|
||||||
|
logging_hooks (bool): Whether to enable debug hook logging.
|
||||||
|
retry (bool): Whether retry behavior is enabled.
|
||||||
|
retry_all (bool): Whether retry behavior should be applied recursively.
|
||||||
|
retry_policy (RetryPolicy | None): Retry configuration for the command.
|
||||||
|
arg_parser (CommandArgumentParser | None): Optional explicit argument
|
||||||
|
parser instance.
|
||||||
|
arguments (list[dict[str, Any]] | None): Declarative argument
|
||||||
|
definitions.
|
||||||
|
argument_config (Callable[[CommandArgumentParser], None] | None):
|
||||||
|
Callback used to configure the argument parser.
|
||||||
|
execution_options (list[ExecutionOption | str] | None): Execution-level
|
||||||
|
options to enable for the command.
|
||||||
|
custom_parser (ArgParserProtocol | None): Optional custom parser
|
||||||
|
implementation.
|
||||||
|
custom_help (Callable[[], str | None] | None): Optional custom help
|
||||||
|
renderer.
|
||||||
|
custom_tldr (Callable[[], str | None] | None): Optional custom TLDR
|
||||||
|
renderer.
|
||||||
|
custom_usage (Callable[[], str | None] | None): Optional custom usage
|
||||||
|
renderer.
|
||||||
|
auto_args (bool): Whether to infer arguments automatically from the
|
||||||
|
action signature.
|
||||||
|
arg_metadata (dict[str, str | dict[str, Any]] | None): Optional
|
||||||
|
metadata used during argument inference.
|
||||||
|
simple_help_signature (bool): Whether to use a simplified help
|
||||||
|
signature.
|
||||||
|
ignore_in_history (bool): Whether to exclude the command from execution
|
||||||
|
history tracking.
|
||||||
|
console (Console | None): Optional Rich console for output.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandRunner: A runner wrapping the newly built command.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotAFalyxError: If `arg_parser` is provided but is not a
|
||||||
|
`CommandArgumentParser` instance.
|
||||||
|
InvalidHookError: If `runner_hooks` is provided but is not a `HookManager`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- This method is intended as a standalone convenience factory.
|
||||||
|
- Command construction is delegated to `Command.build()` so command
|
||||||
|
configuration remains centralized.
|
||||||
|
"""
|
||||||
|
options = options or OptionsManager()
|
||||||
|
command = Command.build(
|
||||||
|
key=key,
|
||||||
|
description=description,
|
||||||
|
action=action,
|
||||||
|
program=program,
|
||||||
|
args=args,
|
||||||
|
kwargs=kwargs,
|
||||||
|
hidden=hidden,
|
||||||
|
aliases=aliases,
|
||||||
|
help_text=help_text,
|
||||||
|
help_epilog=help_epilog,
|
||||||
|
style=style,
|
||||||
|
confirm=confirm,
|
||||||
|
confirm_message=confirm_message,
|
||||||
|
preview_before_confirm=preview_before_confirm,
|
||||||
|
spinner=spinner,
|
||||||
|
spinner_message=spinner_message,
|
||||||
|
spinner_type=spinner_type,
|
||||||
|
spinner_style=spinner_style,
|
||||||
|
spinner_speed=spinner_speed,
|
||||||
|
tags=tags,
|
||||||
|
logging_hooks=logging_hooks,
|
||||||
|
retry=retry,
|
||||||
|
retry_all=retry_all,
|
||||||
|
retry_policy=retry_policy,
|
||||||
|
options_manager=options,
|
||||||
|
hooks=command_hooks,
|
||||||
|
before_hooks=before_hooks,
|
||||||
|
success_hooks=success_hooks,
|
||||||
|
error_hooks=error_hooks,
|
||||||
|
after_hooks=after_hooks,
|
||||||
|
teardown_hooks=teardown_hooks,
|
||||||
|
arg_parser=arg_parser,
|
||||||
|
execution_options=execution_options,
|
||||||
|
arguments=arguments,
|
||||||
|
argument_config=argument_config,
|
||||||
|
custom_parser=custom_parser,
|
||||||
|
custom_help=custom_help,
|
||||||
|
custom_tldr=custom_tldr,
|
||||||
|
custom_usage=custom_usage,
|
||||||
|
auto_args=auto_args,
|
||||||
|
arg_metadata=arg_metadata,
|
||||||
|
simple_help_signature=simple_help_signature,
|
||||||
|
ignore_in_history=ignore_in_history,
|
||||||
|
)
|
||||||
|
|
||||||
|
if runner_hooks and not isinstance(runner_hooks, HookManager):
|
||||||
|
raise InvalidHookError("runner_hooks must be an instance of HookManager.")
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
command=command,
|
||||||
|
options=options,
|
||||||
|
runner_hooks=runner_hooks,
|
||||||
|
console=console,
|
||||||
|
)
|
||||||
257
falyx/completer.py
Normal file
257
falyx/completer.py
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
|
"""Prompt Toolkit completion support for routed Falyx command input.
|
||||||
|
|
||||||
|
This module defines `FalyxCompleter`, the interactive completion layer used by
|
||||||
|
Falyx menu and prompt-driven CLI sessions. The completer is routing-aware: it
|
||||||
|
delegates namespace traversal to `Falyx.resolve_completion_route()` and only
|
||||||
|
hands control to a command's `CommandArgumentParser` after a leaf command has
|
||||||
|
been identified.
|
||||||
|
|
||||||
|
Completion behavior is split into two phases:
|
||||||
|
|
||||||
|
1. Namespace completion
|
||||||
|
While the user is still selecting a command or namespace entry, completion
|
||||||
|
candidates are derived from the active namespace via
|
||||||
|
`completion_names`. Namespace-level help flags such as `-h`, `--help`,
|
||||||
|
`-T`, and `--tldr` are also suggested when appropriate.
|
||||||
|
|
||||||
|
2. Leaf-command completion
|
||||||
|
Once routing reaches a concrete command, the remaining argv fragment is
|
||||||
|
delegated to `CommandArgumentParser.suggest_next()` so command-specific
|
||||||
|
flags, values, choices, and positional suggestions can be surfaced.
|
||||||
|
|
||||||
|
The completer also supports preview-prefixed input such as `?deploy`, preserves
|
||||||
|
shell-safe quoting for suggestions containing whitespace, and integrates
|
||||||
|
directly with Prompt Toolkit's completion API by yielding `Completion`
|
||||||
|
instances.
|
||||||
|
|
||||||
|
Typical usage:
|
||||||
|
session = PromptSession(completer=FalyxCompleter(falyx))
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
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 routed Falyx input.
|
||||||
|
|
||||||
|
`FalyxCompleter` provides context-aware completions for interactive Falyx
|
||||||
|
sessions. It first asks the owning `Falyx` instance to resolve the current
|
||||||
|
input into a partial completion route. Based on that route, it either:
|
||||||
|
|
||||||
|
- suggests visible entries from the active namespace, or
|
||||||
|
- delegates argument completion to the resolved command's argument parser.
|
||||||
|
|
||||||
|
This keeps completion aligned with Falyx's routing model so nested
|
||||||
|
namespaces, preview-prefixed commands, and command-local argument parsing
|
||||||
|
all behave consistently with actual execution.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
falyx (Falyx): Active Falyx application instance used to resolve routes
|
||||||
|
and retrieve completion candidates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, falyx: Falyx):
|
||||||
|
"""Initialize the completer with a bound Falyx instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
falyx (Falyx): Active Falyx application that owns the routing and
|
||||||
|
command metadata used for completion.
|
||||||
|
"""
|
||||||
|
self.falyx = falyx
|
||||||
|
|
||||||
|
def get_completions(self, document: Document, complete_event):
|
||||||
|
"""Yield completions for the current input buffer.
|
||||||
|
|
||||||
|
This method is the main Prompt Toolkit completion entrypoint. It parses
|
||||||
|
the text before the cursor, determines whether the user is still routing
|
||||||
|
through namespaces or has already reached a leaf command, and then
|
||||||
|
yields matching `Completion` objects.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- Splits the current input using `shlex.split()`.
|
||||||
|
- Detects preview-mode input prefixed with `?`.
|
||||||
|
- Separates committed tokens from the active stub under the cursor.
|
||||||
|
- Resolves the partial route through `Falyx.resolve_completion_route()`.
|
||||||
|
- Suggests namespace entries and namespace help flags while routing.
|
||||||
|
- Delegates leaf-command completion to
|
||||||
|
`CommandArgumentParser.suggest_next()` once a command is resolved.
|
||||||
|
- Preserves shell-safe quoting for suggestions containing spaces.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
document (Document): Prompt Toolkit document representing the current
|
||||||
|
input buffer and cursor position.
|
||||||
|
complete_event: Prompt Toolkit completion event metadata. It is not
|
||||||
|
currently inspected directly.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Completion: Completion candidates appropriate to the current routed
|
||||||
|
input state.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Invalid shell quoting causes completion to stop silently rather
|
||||||
|
than raising.
|
||||||
|
- Command-specific completion is only attempted after a concrete leaf
|
||||||
|
command has been resolved.
|
||||||
|
"""
|
||||||
|
text = document.text_before_cursor
|
||||||
|
try:
|
||||||
|
tokens = shlex.split(text)
|
||||||
|
cursor_at_end = text.endswith((" ", "\t"))
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
|
||||||
|
is_preview = False
|
||||||
|
if tokens and tokens[0].startswith("?"):
|
||||||
|
is_preview = True
|
||||||
|
tokens[0] = tokens[0][1:]
|
||||||
|
|
||||||
|
if cursor_at_end:
|
||||||
|
committed_tokens = tokens
|
||||||
|
stub = ""
|
||||||
|
else:
|
||||||
|
committed_tokens = tokens[:-1] if tokens else []
|
||||||
|
stub = tokens[-1] if tokens else ""
|
||||||
|
|
||||||
|
context = self.falyx.get_current_invocation_context().model_copy(
|
||||||
|
update={"is_preview": is_preview}
|
||||||
|
)
|
||||||
|
|
||||||
|
route = self.falyx.resolve_completion_route(
|
||||||
|
committed_tokens,
|
||||||
|
stub=stub,
|
||||||
|
cursor_at_end_of_token=cursor_at_end,
|
||||||
|
invocation_context=context,
|
||||||
|
is_preview=is_preview,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Still selecting an entry in the current namespace
|
||||||
|
if route.expecting_entry:
|
||||||
|
suggestions = self._suggest_namespace_entries(route.namespace, route.stub)
|
||||||
|
|
||||||
|
# Only here should namespace-level help/TLDR be suggested.
|
||||||
|
# TODO: better completer in FalyxParser
|
||||||
|
if not route.command: # and (not route.stub or route.stub.startswith("-")):
|
||||||
|
for flag in route.namespace.parser._options_by_dest:
|
||||||
|
if flag.startswith(route.stub):
|
||||||
|
suggestions.append(flag)
|
||||||
|
|
||||||
|
if route.is_preview:
|
||||||
|
suggestions = [f"?{s}" for s in suggestions]
|
||||||
|
current_stub = f"?{route.stub}" if route.stub else "?"
|
||||||
|
else:
|
||||||
|
current_stub = route.stub
|
||||||
|
|
||||||
|
yield from self._yield_lcp_completions(suggestions, current_stub)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Leaf command: CAP owns the rest
|
||||||
|
if not route.command or not route.command.arg_parser:
|
||||||
|
return
|
||||||
|
|
||||||
|
leaf_tokens = list(route.leaf_argv)
|
||||||
|
if route.stub:
|
||||||
|
leaf_tokens.append(route.stub)
|
||||||
|
|
||||||
|
try:
|
||||||
|
suggestions = route.command.arg_parser.suggest_next(
|
||||||
|
leaf_tokens,
|
||||||
|
route.cursor_at_end_of_token,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
yield from self._yield_lcp_completions(suggestions, route.stub)
|
||||||
|
|
||||||
|
def _suggest_namespace_entries(self, namespace: Falyx, prefix: str) -> list[str]:
|
||||||
|
"""Return matching visible entry names for a namespace prefix.
|
||||||
|
|
||||||
|
This helper filters the current namespace's visible completion names so
|
||||||
|
only entries beginning with the provided prefix are returned. Case of the
|
||||||
|
returned value is adjusted to follow the case style of the typed prefix.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
namespace (Falyx): Namespace whose entries should be searched for
|
||||||
|
completion candidates.
|
||||||
|
prefix (str): Current partially typed entry name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[str]: Matching namespace entry keys and aliases.
|
||||||
|
"""
|
||||||
|
results: list[str] = []
|
||||||
|
for name in namespace.completion_names:
|
||||||
|
if name.upper().startswith(prefix.upper()):
|
||||||
|
results.append(name.lower() if prefix.islower() else name)
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _ensure_quote(self, text: str) -> str:
|
||||||
|
"""Quote a completion candidate when it contains whitespace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text (str): Raw completion candidate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Shell-safe candidate wrapped in double quotes when needed.
|
||||||
|
"""
|
||||||
|
if " " in text or "\t" in text:
|
||||||
|
return f'"{text}"'
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _yield_lcp_completions(self, suggestions, stub) -> Iterable[Completion]:
|
||||||
|
"""Yield completions for the current stub using longest-common-prefix logic.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- If only one match → yield it fully.
|
||||||
|
- If multiple matches share a longer prefix → insert the prefix, but also
|
||||||
|
display all matches in the menu.
|
||||||
|
- If no shared prefix → list all matches individually.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
suggestions (list[str]): The raw suggestions to consider.
|
||||||
|
stub (str): The currently typed prefix (used to offset insertion).
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Completion: Completion objects for the Prompt Toolkit menu.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not suggestions:
|
||||||
|
return
|
||||||
|
|
||||||
|
matches = list(dict.fromkeys(s for s in suggestions if s.startswith(stub)))
|
||||||
|
if not matches:
|
||||||
|
return
|
||||||
|
|
||||||
|
lcp = os.path.commonprefix(matches)
|
||||||
|
|
||||||
|
if len(matches) == 1:
|
||||||
|
match = matches[0]
|
||||||
|
yield Completion(
|
||||||
|
self._ensure_quote(match),
|
||||||
|
start_position=-len(stub),
|
||||||
|
display=match,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if len(lcp) > len(stub) and not lcp.startswith("-"):
|
||||||
|
yield Completion(
|
||||||
|
self._ensure_quote(lcp),
|
||||||
|
start_position=-len(stub),
|
||||||
|
display=lcp,
|
||||||
|
)
|
||||||
|
|
||||||
|
for match in matches:
|
||||||
|
yield Completion(
|
||||||
|
self._ensure_quote(match),
|
||||||
|
start_position=-len(stub),
|
||||||
|
display=match,
|
||||||
|
)
|
||||||
87
falyx/completer_types.py
Normal file
87
falyx/completer_types.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
|
"""Completion route models for routed Falyx autocompletion.
|
||||||
|
|
||||||
|
This module defines `CompletionRoute`, a lightweight value object used by the
|
||||||
|
Falyx completion system to describe the partially resolved state of interactive
|
||||||
|
input during autocompletion.
|
||||||
|
|
||||||
|
`CompletionRoute` sits at the boundary between namespace routing and
|
||||||
|
command-local argument completion. It captures enough information for the
|
||||||
|
completer to determine whether it should continue suggesting namespace entries
|
||||||
|
or delegate to a resolved command's argument parser.
|
||||||
|
|
||||||
|
Typical usage:
|
||||||
|
- A user types part of a namespace path or command key.
|
||||||
|
- Falyx resolves as much of that path as possible.
|
||||||
|
- The resulting `CompletionRoute` describes the active namespace, any
|
||||||
|
resolved leaf command, the remaining argv fragment, and the current
|
||||||
|
token stub under the cursor.
|
||||||
|
- `FalyxCompleter` uses this information to decide what completions to
|
||||||
|
surface next.
|
||||||
|
|
||||||
|
This module is intentionally small and focused. It does not perform routing or
|
||||||
|
completion itself; it only models the routed state needed by the completer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from falyx.context import InvocationContext
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from falyx.command import Command
|
||||||
|
from falyx.falyx import Falyx
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class CompletionRoute:
|
||||||
|
"""Represents a partially resolved route used during autocompletion.
|
||||||
|
|
||||||
|
A `CompletionRoute` describes the current routed state of user input while
|
||||||
|
Falyx is generating interactive completions. It distinguishes between two
|
||||||
|
broad states:
|
||||||
|
|
||||||
|
- namespace-routing state, where the user is still selecting a visible entry
|
||||||
|
within the current namespace
|
||||||
|
- leaf-command state, where a concrete command has been resolved and the
|
||||||
|
remaining input should be completed by that command's argument parser
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
namespace (Falyx): The active namespace in which completion is currently
|
||||||
|
taking place.
|
||||||
|
context (InvocationContext): Invocation-path context used to preserve the
|
||||||
|
routed command path and render context-aware help or usage text.
|
||||||
|
command (Command | None): The resolved leaf command, if routing has
|
||||||
|
already reached a concrete command. Remains `None` while the user is
|
||||||
|
still navigating namespaces.
|
||||||
|
leaf_argv (list[str]): Remaining command-local argv tokens that belong to
|
||||||
|
the resolved leaf command. These are typically passed to the
|
||||||
|
command's argument parser for completion.
|
||||||
|
stub (str): The current token fragment under the cursor. This is the
|
||||||
|
partial text that completion candidates should replace or extend.
|
||||||
|
cursor_at_end_of_token (bool): Whether the cursor is positioned at the
|
||||||
|
end of a completed token boundary, such as immediately after a
|
||||||
|
trailing space.
|
||||||
|
expecting_entry (bool): Whether completion should suggest namespace
|
||||||
|
entries rather than command-local arguments.
|
||||||
|
is_preview (bool): Whether the input is in preview mode, such as when
|
||||||
|
the user begins the invocation with `?`.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- This model is completion-only and is intentionally separate from
|
||||||
|
full execution routing types such as `RouteResult`.
|
||||||
|
- `CompletionRoute` does not validate or parse command arguments; it
|
||||||
|
only records the routed state needed to decide what should complete
|
||||||
|
next.
|
||||||
|
"""
|
||||||
|
|
||||||
|
namespace: Falyx
|
||||||
|
context: InvocationContext
|
||||||
|
command: Command | None = None
|
||||||
|
leaf_argv: list[str] = field(default_factory=list)
|
||||||
|
stub: str = ""
|
||||||
|
cursor_at_end_of_token: bool = False
|
||||||
|
expecting_entry: bool = False
|
||||||
|
is_preview: bool = False
|
||||||
@@ -1,6 +1,40 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""config.py
|
"""Configuration loader and schema definitions for the Falyx CLI framework.
|
||||||
Configuration loader for Falyx CLI commands."""
|
|
||||||
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
@@ -11,17 +45,16 @@ from typing import Any, Callable
|
|||||||
import toml
|
import toml
|
||||||
import yaml
|
import yaml
|
||||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
from rich.console import Console
|
|
||||||
|
|
||||||
from 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.command import Command
|
||||||
|
from falyx.console import console
|
||||||
from falyx.falyx import Falyx
|
from falyx.falyx import Falyx
|
||||||
from falyx.logger import logger
|
from falyx.logger import logger
|
||||||
from falyx.retry import RetryPolicy
|
from falyx.retry import RetryPolicy
|
||||||
from falyx.themes import OneColors
|
from falyx.themes import OneColors
|
||||||
|
|
||||||
console = Console(color_system="auto")
|
|
||||||
|
|
||||||
|
|
||||||
def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command:
|
def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command:
|
||||||
if isinstance(obj, (BaseAction, Command)):
|
if isinstance(obj, (BaseAction, Command)):
|
||||||
@@ -86,7 +119,7 @@ class RawCommand(BaseModel):
|
|||||||
spinner_message: str = "Processing..."
|
spinner_message: str = "Processing..."
|
||||||
spinner_type: str = "dots"
|
spinner_type: str = "dots"
|
||||||
spinner_style: str = OneColors.CYAN
|
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)
|
before_hooks: list[Callable] = Field(default_factory=list)
|
||||||
success_hooks: list[Callable] = Field(default_factory=list)
|
success_hooks: list[Callable] = Field(default_factory=list)
|
||||||
@@ -100,6 +133,7 @@ class RawCommand(BaseModel):
|
|||||||
retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
|
retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
|
||||||
hidden: bool = False
|
hidden: bool = False
|
||||||
help_text: str = ""
|
help_text: str = ""
|
||||||
|
help_epilog: str = ""
|
||||||
|
|
||||||
@field_validator("retry_policy")
|
@field_validator("retry_policy")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -125,6 +159,7 @@ def convert_commands(raw_commands: list[dict[str, Any]]) -> list[Command]:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return commands
|
return commands
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
18
falyx/console.py
Normal file
18
falyx/console.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
|
"""Global console instance for Falyx CLI applications."""
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from falyx.themes import OneColors, get_nord_theme
|
||||||
|
|
||||||
|
console = Console(color_system="truecolor", theme=get_nord_theme())
|
||||||
|
error_console = Console(color_system="truecolor", theme=get_nord_theme(), stderr=True)
|
||||||
|
|
||||||
|
|
||||||
|
def print_error(
|
||||||
|
message: str | Exception,
|
||||||
|
*,
|
||||||
|
hint: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
error_console.print(f"[{OneColors.DARK_RED}]error:[/] {message}")
|
||||||
|
if hint:
|
||||||
|
error_console.print(f"[{OneColors.LIGHT_YELLOW}]hint:[/] {hint}")
|
||||||
234
falyx/context.py
234
falyx/context.py
@@ -1,28 +1,38 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Context models for Falyx execution and invocation state.
|
||||||
Execution context management for Falyx CLI actions.
|
|
||||||
|
|
||||||
This module defines `ExecutionContext` and `SharedContext`, which are responsible for
|
This module defines the core context objects used throughout Falyx to track both
|
||||||
capturing per-action and cross-action metadata during CLI workflow execution. These
|
runtime execution metadata and routed invocation-path state.
|
||||||
context objects provide structured introspection, result tracking, error recording,
|
|
||||||
and time-based performance metrics.
|
|
||||||
|
|
||||||
- `ExecutionContext`: Captures runtime information for a single action execution,
|
It provides:
|
||||||
including arguments, results, exceptions, timing, and logging.
|
- `ExecutionContext` for per-action execution details such as arguments,
|
||||||
- `SharedContext`: Maintains shared state and result propagation across
|
results, exceptions, timing, and summary logging.
|
||||||
`ChainedAction` or `ActionGroup` executions.
|
- `SharedContext` for transient shared state across grouped or chained
|
||||||
|
actions, including propagated results, indexed errors, and arbitrary
|
||||||
|
shared data.
|
||||||
|
- `InvocationContext` for capturing the current routed command path as an
|
||||||
|
immutable value object that supports both plain-text and Rich-markup
|
||||||
|
rendering.
|
||||||
|
|
||||||
These contexts enable rich introspection, traceability, and workflow coordination,
|
Together, these models support Falyx lifecycle hooks, execution tracing,
|
||||||
supporting hook lifecycles, retries, and structured output generation.
|
history/introspection, and context-aware help and usage rendering across CLI
|
||||||
|
and menu modes.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from traceback import format_exception
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
from rich.markup import escape
|
||||||
|
from rich.style import Style
|
||||||
|
|
||||||
|
from falyx.console import console
|
||||||
|
from falyx.display_types import StyledSegment
|
||||||
|
from falyx.mode import FalyxMode
|
||||||
|
|
||||||
|
|
||||||
class ExecutionContext(BaseModel):
|
class ExecutionContext(BaseModel):
|
||||||
@@ -40,7 +50,7 @@ class ExecutionContext(BaseModel):
|
|||||||
kwargs (dict): Keyword arguments passed to the action.
|
kwargs (dict): Keyword arguments passed to the action.
|
||||||
action (BaseAction | Callable): The action instance being executed.
|
action (BaseAction | Callable): The action instance being executed.
|
||||||
result (Any | None): The result of the action, if successful.
|
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.
|
start_time (float | None): High-resolution performance start time.
|
||||||
end_time (float | None): High-resolution performance end time.
|
end_time (float | None): High-resolution performance end time.
|
||||||
start_wall (datetime | None): Wall-clock timestamp when execution began.
|
start_wall (datetime | None): Wall-clock timestamp when execution began.
|
||||||
@@ -70,18 +80,21 @@ class ExecutionContext(BaseModel):
|
|||||||
|
|
||||||
name: str
|
name: str
|
||||||
args: tuple = ()
|
args: tuple = ()
|
||||||
kwargs: dict = {}
|
kwargs: dict = Field(default_factory=dict)
|
||||||
action: Any
|
action: Any
|
||||||
result: Any | None = None
|
result: Any | None = None
|
||||||
exception: Exception | None = None
|
traceback: str | None = None
|
||||||
|
_exception: BaseException | None = None
|
||||||
|
|
||||||
start_time: float | None = None
|
start_time: float | None = None
|
||||||
end_time: float | None = None
|
end_time: float | None = None
|
||||||
start_wall: datetime | None = None
|
start_wall: datetime | None = None
|
||||||
end_wall: datetime | None = None
|
end_wall: datetime | None = None
|
||||||
|
|
||||||
|
index: int | None = None
|
||||||
|
|
||||||
extra: dict[str, Any] = Field(default_factory=dict)
|
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
|
shared_context: SharedContext | None = None
|
||||||
|
|
||||||
@@ -118,11 +131,33 @@ class ExecutionContext(BaseModel):
|
|||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
return "OK" if self.success else "ERROR"
|
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:
|
def as_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"result": self.result,
|
"result": self.result,
|
||||||
"exception": repr(self.exception) if self.exception else None,
|
"exception": repr(self.exception) if self.exception else None,
|
||||||
|
"traceback": self.traceback,
|
||||||
"duration": self.duration,
|
"duration": self.duration,
|
||||||
"extra": self.extra,
|
"extra": self.extra,
|
||||||
}
|
}
|
||||||
@@ -140,9 +175,9 @@ class ExecutionContext(BaseModel):
|
|||||||
message.append(f"Duration: {summary['duration']:.3f}s | ")
|
message.append(f"Duration: {summary['duration']:.3f}s | ")
|
||||||
|
|
||||||
if summary["exception"]:
|
if summary["exception"]:
|
||||||
message.append(f"❌ Exception: {summary['exception']}")
|
message.append(f"Exception: {summary['exception']}")
|
||||||
else:
|
else:
|
||||||
message.append(f"✅ Result: {summary['result']}")
|
message.append(f"Result: {summary['result']}")
|
||||||
(logger or self.console.print)("".join(message))
|
(logger or self.console.print)("".join(message))
|
||||||
|
|
||||||
def to_log_line(self) -> str:
|
def to_log_line(self) -> str:
|
||||||
@@ -192,11 +227,11 @@ class SharedContext(BaseModel):
|
|||||||
Attributes:
|
Attributes:
|
||||||
name (str): Identifier for the context (usually the parent action name).
|
name (str): Identifier for the context (usually the parent action name).
|
||||||
results (list[Any]): Captures results from each action, in order of execution.
|
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).
|
current_index (int): Index of the currently executing action (used in chains).
|
||||||
is_parallel (bool): Whether the context is used in parallel mode (ActionGroup).
|
is_concurrent (bool): Whether the context is used in concurrent mode (ActionGroup).
|
||||||
shared_result (Any | None): Optional shared value available to all actions in
|
shared_result (Any | None): Optional shared value available to all actions in
|
||||||
parallel mode.
|
concurrent mode.
|
||||||
share (dict[str, Any]): Custom shared key-value store for user-defined
|
share (dict[str, Any]): Custom shared key-value store for user-defined
|
||||||
communication
|
communication
|
||||||
between actions (e.g., flags, intermediate data, settings).
|
between actions (e.g., flags, intermediate data, settings).
|
||||||
@@ -217,9 +252,9 @@ class SharedContext(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
action: Any
|
action: Any
|
||||||
results: list[Any] = Field(default_factory=list)
|
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
|
current_index: int = -1
|
||||||
is_parallel: bool = False
|
is_concurrent: bool = False
|
||||||
shared_result: Any | None = None
|
shared_result: Any | None = None
|
||||||
|
|
||||||
share: dict[str, Any] = Field(default_factory=dict)
|
share: dict[str, Any] = Field(default_factory=dict)
|
||||||
@@ -229,16 +264,16 @@ class SharedContext(BaseModel):
|
|||||||
def add_result(self, result: Any) -> None:
|
def add_result(self, result: Any) -> None:
|
||||||
self.results.append(result)
|
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))
|
self.errors.append((index, error))
|
||||||
|
|
||||||
def set_shared_result(self, result: Any) -> None:
|
def set_shared_result(self, result: Any) -> None:
|
||||||
self.shared_result = result
|
self.shared_result = result
|
||||||
if self.is_parallel:
|
if self.is_concurrent:
|
||||||
self.results.append(result)
|
self.results.append(result)
|
||||||
|
|
||||||
def last_result(self) -> Any:
|
def last_result(self) -> Any:
|
||||||
if self.is_parallel:
|
if self.is_concurrent:
|
||||||
return self.shared_result
|
return self.shared_result
|
||||||
return self.results[-1] if self.results else None
|
return self.results[-1] if self.results else None
|
||||||
|
|
||||||
@@ -249,14 +284,155 @@ class SharedContext(BaseModel):
|
|||||||
self.share[key] = value
|
self.share[key] = value
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
parallel_label = "Parallel" if self.is_parallel else "Sequential"
|
concurrent_label = "Concurrent" if self.is_concurrent else "Sequential"
|
||||||
return (
|
return (
|
||||||
f"<{parallel_label}SharedContext '{self.name}' | "
|
f"<{concurrent_label}SharedContext '{self.name}' | "
|
||||||
f"Results: {self.results} | "
|
f"Results: {self.results} | "
|
||||||
f"Errors: {self.errors}>"
|
f"Errors: {self.errors}>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InvocationContext(BaseModel):
|
||||||
|
"""Immutable invocation-path context for routed Falyx help and execution.
|
||||||
|
|
||||||
|
`InvocationContext` captures the current displayable command path as the router
|
||||||
|
descends through namespaces and commands. It stores both the raw typed path
|
||||||
|
(`typed_path`) and a styled segment representation (`segments`) so the same
|
||||||
|
context can be rendered as plain text or Rich markup.
|
||||||
|
|
||||||
|
This model is intended to be treated as an immutable value object. Methods such
|
||||||
|
as `with_path_segment()` and `without_last_path_segment()` return new context
|
||||||
|
instances rather than mutating the existing one.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
program (str): Root program name used in CLI-mode help and usage output.
|
||||||
|
program_style (Style | str): Rich style applied to the program name when rendering
|
||||||
|
`markup_path`.
|
||||||
|
typed_path (list[str]): Raw invocation tokens collected during routing,
|
||||||
|
excluding the root program name.
|
||||||
|
segments (list[StyledSegment]): Styled path segments used to render the
|
||||||
|
invocation path with Rich markup.
|
||||||
|
mode (FalyxMode): Active Falyx mode for this invocation context. This is
|
||||||
|
used to determine whether the path should include the program name.
|
||||||
|
is_preview (bool): Whether the current invocation is a preview flow rather
|
||||||
|
than a normal execution flow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
program: str = ""
|
||||||
|
program_style: Style | str = ""
|
||||||
|
typed_path: list[str] = Field(default_factory=list)
|
||||||
|
segments: list[StyledSegment] = Field(default_factory=list)
|
||||||
|
mode: FalyxMode = FalyxMode.MENU
|
||||||
|
is_preview: bool = False
|
||||||
|
|
||||||
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_cli_mode(self) -> bool:
|
||||||
|
"""Whether this context should render using CLI path semantics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: `True` when the invocation is not in menu mode, meaning rendered
|
||||||
|
paths should include the program name. `False` when in menu mode.
|
||||||
|
"""
|
||||||
|
return self.mode != FalyxMode.MENU
|
||||||
|
|
||||||
|
def with_path_segment(
|
||||||
|
self,
|
||||||
|
token: str,
|
||||||
|
*,
|
||||||
|
style: Style | str | None = None,
|
||||||
|
) -> InvocationContext:
|
||||||
|
"""Return a new context with one additional path segment appended.
|
||||||
|
|
||||||
|
This method preserves the current context and creates a new
|
||||||
|
`InvocationContext` with the provided token added to both `typed_path` and
|
||||||
|
`segments`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token (str): Raw path token to append, such as a namespace key,
|
||||||
|
command key, or alias.
|
||||||
|
style (str | None): Optional Rich style for the appended segment.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
InvocationContext: A new context containing the appended path segment.
|
||||||
|
"""
|
||||||
|
return InvocationContext(
|
||||||
|
program=self.program,
|
||||||
|
program_style=self.program_style,
|
||||||
|
typed_path=[*self.typed_path, token],
|
||||||
|
segments=[*self.segments, StyledSegment(text=token, style=style)],
|
||||||
|
mode=self.mode,
|
||||||
|
is_preview=self.is_preview,
|
||||||
|
)
|
||||||
|
|
||||||
|
def without_last_path_segment(self) -> InvocationContext:
|
||||||
|
"""Return a new context with the last path segment removed.
|
||||||
|
|
||||||
|
This method preserves the current context and creates a new
|
||||||
|
`InvocationContext` with the last token removed from both `typed_path` and
|
||||||
|
`segments`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
InvocationContext: A new context with the last path segment removed, or the
|
||||||
|
current context if no path segments are present.
|
||||||
|
"""
|
||||||
|
if not self.typed_path:
|
||||||
|
return self
|
||||||
|
return InvocationContext(
|
||||||
|
program=self.program,
|
||||||
|
program_style=self.program_style,
|
||||||
|
typed_path=self.typed_path[:-1],
|
||||||
|
segments=self.segments[:-1],
|
||||||
|
mode=self.mode,
|
||||||
|
is_preview=self.is_preview,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def plain_path(self) -> str:
|
||||||
|
"""Render the invocation path as plain text.
|
||||||
|
|
||||||
|
In CLI mode, the rendered path includes the root program name followed by
|
||||||
|
all collected path segments. In menu mode, only the collected path segments
|
||||||
|
are rendered.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Plain-text invocation path suitable for logs, comparisons, or
|
||||||
|
non-styled help output.
|
||||||
|
"""
|
||||||
|
parts = [seg.text for seg in self.segments]
|
||||||
|
if self.is_cli_mode:
|
||||||
|
return " ".join([self.program, *parts]).strip()
|
||||||
|
return " ".join(parts).strip()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def markup_path(self) -> str:
|
||||||
|
"""Render the invocation path as escaped Rich markup.
|
||||||
|
|
||||||
|
In CLI mode, the root program name is included and styled with
|
||||||
|
`program_style` when provided. Each path segment is escaped and styled
|
||||||
|
using its associated `StyledSegment.style` value when present.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Rich-markup invocation path suitable for help and usage rendering.
|
||||||
|
"""
|
||||||
|
parts: list[str] = []
|
||||||
|
if self.is_cli_mode and self.program:
|
||||||
|
if self.program_style:
|
||||||
|
parts.append(
|
||||||
|
f"[{self.program_style}]{escape(self.program)}[/{self.program_style}]"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
parts.append(escape(self.program))
|
||||||
|
|
||||||
|
for seg in self.segments:
|
||||||
|
if seg.style:
|
||||||
|
parts.append(f"[{seg.style}]{escape(seg.text)}[/{seg.style}]")
|
||||||
|
else:
|
||||||
|
parts.append(escape(seg.text))
|
||||||
|
return " ".join(parts).strip()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 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.context import ExecutionContext
|
||||||
from falyx.hook_manager import HookManager, HookType
|
from falyx.hook_manager import HookManager, HookType
|
||||||
from falyx.logger import logger
|
from falyx.logger import logger
|
||||||
@@ -8,9 +20,9 @@ from falyx.logger import logger
|
|||||||
def log_before(context: ExecutionContext):
|
def log_before(context: ExecutionContext):
|
||||||
"""Log the start of an action."""
|
"""Log the start of an action."""
|
||||||
args = ", ".join(map(repr, context.args))
|
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]))
|
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):
|
def log_success(context: ExecutionContext):
|
||||||
@@ -18,18 +30,18 @@ def log_success(context: ExecutionContext):
|
|||||||
result_str = repr(context.result)
|
result_str = repr(context.result)
|
||||||
if len(result_str) > 100:
|
if len(result_str) > 100:
|
||||||
result_str = f"{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):
|
def log_after(context: ExecutionContext):
|
||||||
"""Log the completion of an action, regardless of success or failure."""
|
"""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):
|
def log_error(context: ExecutionContext):
|
||||||
"""Log an error that occurred during the action."""
|
"""Log an error that occurred during the action."""
|
||||||
logger.error(
|
logger.error(
|
||||||
"[%s] ❌ Error (%s): %s",
|
"[%s] Error (%s): %s",
|
||||||
context.name,
|
context.name,
|
||||||
type(context.exception).__name__,
|
type(context.exception).__name__,
|
||||||
context.exception,
|
context.exception,
|
||||||
|
|||||||
33
falyx/display_types.py
Normal file
33
falyx/display_types.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
|
"""Display types for Falyx.
|
||||||
|
|
||||||
|
This module defines data models used for representing styled display elements in
|
||||||
|
Falyx's CLI output, such as command paths, namespaces, and TLDR examples. These
|
||||||
|
models are designed to be simple containers for the raw text and styling
|
||||||
|
information needed to render consistent and visually appealing CLI interfaces using
|
||||||
|
the Rich library.
|
||||||
|
|
||||||
|
It provides:
|
||||||
|
- `StyledSegment` for representing a single styled token.
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from rich.style import Style
|
||||||
|
|
||||||
|
|
||||||
|
class StyledSegment(BaseModel):
|
||||||
|
"""Styled path segment used to build Rich styled markup.
|
||||||
|
|
||||||
|
`StyledSegment` represents a single token. It stores the raw display
|
||||||
|
text and an optional Rich style so text can be rendered either
|
||||||
|
as plain text or styled markup.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
text (str): Display text for this path segment.
|
||||||
|
style (str | None): Optional Rich style applied when rendering this
|
||||||
|
segment in markup output.
|
||||||
|
"""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
style: Style | str | None = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
@@ -1,13 +1,45 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 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
|
||||||
|
└── EntryNotFoundError
|
||||||
|
|
||||||
|
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):
|
class FalyxError(Exception):
|
||||||
"""Custom exception for the Menu class."""
|
"""Base exception class for all Falyx CLI framework errors."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str | None = None,
|
||||||
|
hint: str | None = None,
|
||||||
|
):
|
||||||
|
if message:
|
||||||
|
super().__init__(message)
|
||||||
|
self.hint = hint
|
||||||
|
|
||||||
|
|
||||||
class CommandAlreadyExistsError(FalyxError):
|
class CommandAlreadyExistsError(FalyxError):
|
||||||
"""Exception raised when an command with the same key already exists in the menu."""
|
"""Exception raised when an command with the same key already exists in the Falyx instance."""
|
||||||
|
|
||||||
|
|
||||||
class InvalidHookError(FalyxError):
|
class InvalidHookError(FalyxError):
|
||||||
@@ -19,7 +51,7 @@ class InvalidActionError(FalyxError):
|
|||||||
|
|
||||||
|
|
||||||
class NotAFalyxError(FalyxError):
|
class NotAFalyxError(FalyxError):
|
||||||
"""Exception raised when the provided submenu is not an instance of Menu."""
|
"""Exception raised when the provided object is not an instance of a Falyx class."""
|
||||||
|
|
||||||
|
|
||||||
class CircuitBreakerOpen(FalyxError):
|
class CircuitBreakerOpen(FalyxError):
|
||||||
@@ -30,5 +62,160 @@ class EmptyChainError(FalyxError):
|
|||||||
"""Exception raised when the chain is empty."""
|
"""Exception raised when the chain is empty."""
|
||||||
|
|
||||||
|
|
||||||
class CommandArgumentError(FalyxError):
|
class EmptyGroupError(FalyxError):
|
||||||
|
"""Exception raised when the group is empty."""
|
||||||
|
|
||||||
|
|
||||||
|
class EmptyPoolError(FalyxError):
|
||||||
|
"""Exception raised when the pool is empty."""
|
||||||
|
|
||||||
|
|
||||||
|
class UsageError(FalyxError):
|
||||||
|
"""Exception raised when there is an error in the command usage."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str | None = None,
|
||||||
|
hint: str | None = None,
|
||||||
|
show_short_usage: bool = True,
|
||||||
|
):
|
||||||
|
super().__init__(message, hint)
|
||||||
|
self.show_short_usage = show_short_usage
|
||||||
|
|
||||||
|
|
||||||
|
class FalyxOptionError(UsageError):
|
||||||
|
"""Exception raised when there is an error in the Falyx option parser."""
|
||||||
|
|
||||||
|
|
||||||
|
class CommandArgumentError(UsageError):
|
||||||
"""Exception raised when there is an error in the command argument parser."""
|
"""Exception raised when there is an error in the command argument parser."""
|
||||||
|
|
||||||
|
|
||||||
|
class ArgumentGroupError(CommandArgumentError):
|
||||||
|
"""Exception raised when there is an error in the argument group."""
|
||||||
|
|
||||||
|
|
||||||
|
class ArgumentParsingError(CommandArgumentError):
|
||||||
|
"""Exception raised when there is an error during argument parsing."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str | None = None,
|
||||||
|
hint: str | None = None,
|
||||||
|
show_short_usage: bool = True,
|
||||||
|
command_key: str | None = None,
|
||||||
|
dest: str | None = None,
|
||||||
|
token: str | None = None,
|
||||||
|
):
|
||||||
|
self.command_key = command_key
|
||||||
|
self.dest = dest
|
||||||
|
self.token = token
|
||||||
|
super().__init__(message, hint, show_short_usage)
|
||||||
|
|
||||||
|
|
||||||
|
class EntryNotFoundError(UsageError):
|
||||||
|
"""Exception raised when a routing entry is not found."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
unknown_name: str,
|
||||||
|
suggestions: list[str] | None = None,
|
||||||
|
message_context: str = "",
|
||||||
|
show_short_usage: bool = True,
|
||||||
|
):
|
||||||
|
self.unknown_name = unknown_name
|
||||||
|
self.suggestions = suggestions
|
||||||
|
self.message_context = message_context
|
||||||
|
super().__init__(
|
||||||
|
self.build_message(),
|
||||||
|
self.build_hint(),
|
||||||
|
show_short_usage,
|
||||||
|
)
|
||||||
|
|
||||||
|
def build_message(self) -> str:
|
||||||
|
prefix = f"{self.message_context}: " if self.message_context else ""
|
||||||
|
return f"{prefix}unknown command or namespace '{self.unknown_name}'."
|
||||||
|
|
||||||
|
def build_hint(self) -> str | None:
|
||||||
|
if self.suggestions:
|
||||||
|
return f"did you mean: {', '.join(self.suggestions[:10])}?"
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class UnrecognizedOptionError(ArgumentParsingError):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
token: str,
|
||||||
|
remaining_flags: list[str] | None = None,
|
||||||
|
show_short_usage: bool = True,
|
||||||
|
):
|
||||||
|
self.remaining_flags = remaining_flags
|
||||||
|
self.token = token
|
||||||
|
super().__init__(
|
||||||
|
self.build_message(),
|
||||||
|
self.build_hint(),
|
||||||
|
show_short_usage=show_short_usage,
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
|
||||||
|
def build_message(self) -> str:
|
||||||
|
return f"unrecognized option '{self.token}'"
|
||||||
|
|
||||||
|
def build_hint(self) -> str:
|
||||||
|
if self.remaining_flags:
|
||||||
|
return f"did you mean one of: {', '.join(self.remaining_flags)}?"
|
||||||
|
return "use --help to see available options"
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidValueError(ArgumentParsingError):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
dest: str | None = None,
|
||||||
|
choices: list[str] | None = None,
|
||||||
|
expected: str | None = None,
|
||||||
|
error: Exception | str | None = None,
|
||||||
|
show_short_usage: bool = True,
|
||||||
|
):
|
||||||
|
self.choices = choices
|
||||||
|
self.expected = expected
|
||||||
|
self.error = error
|
||||||
|
self.dest = dest
|
||||||
|
super().__init__(
|
||||||
|
self.build_message(),
|
||||||
|
self.build_hint(),
|
||||||
|
show_short_usage=show_short_usage,
|
||||||
|
dest=dest,
|
||||||
|
)
|
||||||
|
|
||||||
|
def build_message(self) -> str:
|
||||||
|
if self.dest and self.choices:
|
||||||
|
return f"invalid value for '{self.dest}'"
|
||||||
|
elif self.dest and self.error:
|
||||||
|
return f"invalid value for '{self.dest}': {self.error}"
|
||||||
|
elif self.dest and self.expected:
|
||||||
|
return f"invalid value for '{self.dest}': expected {self.expected}"
|
||||||
|
else:
|
||||||
|
return "invalid command argument value."
|
||||||
|
|
||||||
|
def build_hint(self) -> str | None:
|
||||||
|
if self.dest and self.choices:
|
||||||
|
return f"the value for '{self.dest}' must be one of {{{', '.join(self.choices)}}}."
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class MissingValueError(ArgumentParsingError):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
dest: str,
|
||||||
|
expected_count: int | None = None,
|
||||||
|
actual_count: int | None = None,
|
||||||
|
):
|
||||||
|
self.expected_count = expected_count
|
||||||
|
self.actual_count = actual_count
|
||||||
|
self.dest = dest
|
||||||
|
|
||||||
|
|
||||||
|
class TokenizationError(UsageError):
|
||||||
|
raw_input: str | None = None
|
||||||
|
|||||||
61
falyx/execution_option.py
Normal file
61
falyx/execution_option.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
|
"""Execution option enums for the Falyx command runtime.
|
||||||
|
|
||||||
|
This module defines `ExecutionOption`, the enum used to represent optional
|
||||||
|
execution-scoped behaviors that a command may choose to expose through its
|
||||||
|
argument parser.
|
||||||
|
|
||||||
|
Execution options are distinct from normal command inputs. They control runtime
|
||||||
|
behavior around command execution rather than the business-logic arguments
|
||||||
|
passed to the underlying action. Typical examples include summary output,
|
||||||
|
retry configuration, and confirmation handling.
|
||||||
|
|
||||||
|
`ExecutionOption` is used by Falyx components such as `Command` and
|
||||||
|
`CommandArgumentParser` to declaratively enable execution-level flags and to
|
||||||
|
normalize user- or config-provided option names into a validated enum value.
|
||||||
|
|
||||||
|
The enum also implements custom missing-value handling so string inputs can be
|
||||||
|
resolved case-insensitively with helpful error messages.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionOption(Enum):
|
||||||
|
"""Enumerates optional execution-scoped behaviors supported by Falyx.
|
||||||
|
|
||||||
|
`ExecutionOption` identifies runtime features that can be enabled on a
|
||||||
|
command independently of its normal argument schema. When present, these
|
||||||
|
options typically cause `CommandArgumentParser` to expose additional flags
|
||||||
|
that affect how the command is executed rather than what the command does.
|
||||||
|
|
||||||
|
Supported options:
|
||||||
|
SUMMARY: Enable summary-related execution flags and reporting behavior.
|
||||||
|
RETRY: Enable retry-related execution flags such as retry count, delay,
|
||||||
|
and backoff.
|
||||||
|
CONFIRM: Enable confirmation-related execution flags such as forcing or
|
||||||
|
skipping confirmation prompts.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- These values are intended for execution control, not domain-specific
|
||||||
|
command input.
|
||||||
|
- String values are normalized case-insensitively through `_missing_()`
|
||||||
|
so config and user input can be converted into enum members with
|
||||||
|
friendlier validation behavior.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SUMMARY = "summary"
|
||||||
|
RETRY = "retry"
|
||||||
|
CONFIRM = "confirm"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _missing_(cls, value: object) -> ExecutionOption:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(f"Invalid {cls.__name__}: {value!r}")
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
for member in cls:
|
||||||
|
if member.value == normalized:
|
||||||
|
return member
|
||||||
|
valid = ", ".join(member.value for member in cls)
|
||||||
|
raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}")
|
||||||
@@ -1,107 +1,238 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Provides the `ExecutionRegistry`, a centralized runtime store for capturing and
|
||||||
execution_registry.py
|
inspecting the execution history of Falyx actions.
|
||||||
|
|
||||||
This module provides the `ExecutionRegistry`, a global class for tracking and
|
The registry automatically records every `ExecutionContext` created during action
|
||||||
introspecting the execution history of Falyx actions.
|
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
|
Designed for:
|
||||||
easy to debug, audit, and visualize workflow behavior over time. It supports retrieval,
|
- Workflow debugging and CLI diagnostics
|
||||||
filtering, clearing, and formatted summary display.
|
- Interactive history browsing or replaying previous runs
|
||||||
|
- Providing user-visible `history` or `last-result` commands inside CLI apps
|
||||||
|
|
||||||
Core Features:
|
Key Features:
|
||||||
- Stores all action execution contexts globally (with access by name).
|
- Global, in-memory store of all `ExecutionContext` objects (by name, index, or full list)
|
||||||
- Provides live execution summaries in a rich table format.
|
- Thread-safe indexing and summary display
|
||||||
- Enables creation of a built-in Falyx Action to print history on demand.
|
- Traceback-aware result inspection and filtering by status (success/error)
|
||||||
- Integrates with Falyx's introspectable and hook-driven execution model.
|
- Used by built-in `History` command in Falyx CLI
|
||||||
|
|
||||||
Intended for:
|
|
||||||
- Debugging and diagnostics
|
|
||||||
- Post-run inspection of CLI workflows
|
|
||||||
- Interactive tools built with Falyx
|
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
|
|
||||||
|
# Record a context
|
||||||
er.record(context)
|
er.record(context)
|
||||||
|
|
||||||
|
# Display a rich table summary
|
||||||
er.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 __future__ import annotations
|
||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, List
|
from threading import Lock
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from rich import box
|
from rich import box
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
|
from falyx.console import console
|
||||||
from falyx.context import ExecutionContext
|
from falyx.context import ExecutionContext
|
||||||
from falyx.logger import logger
|
from falyx.logger import logger
|
||||||
from falyx.themes import OneColors
|
from falyx.themes import OneColors
|
||||||
|
|
||||||
|
|
||||||
class ExecutionRegistry:
|
class ExecutionRegistry:
|
||||||
"""
|
"""Global registry for recording and inspecting Falyx action executions.
|
||||||
Global registry for recording and inspecting Falyx action executions.
|
|
||||||
|
|
||||||
This class captures every `ExecutionContext` generated by a Falyx `Action`,
|
This class captures every `ExecutionContext` created by Falyx Actions,
|
||||||
`ChainedAction`, or `ActionGroup`, maintaining both full history and
|
tracking metadata, results, exceptions, and performance metrics. It enables
|
||||||
name-indexed access for filtered analysis.
|
rich introspection, post-execution inspection, and formatted summaries
|
||||||
|
suitable for interactive and headless CLI use.
|
||||||
|
|
||||||
Methods:
|
Data is retained in memory until cleared or process exit.
|
||||||
- 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.
|
|
||||||
|
|
||||||
Use Cases:
|
Use Cases:
|
||||||
- Debugging chained or factory-generated workflows
|
- Auditing chained or dynamic workflows
|
||||||
- Viewing results and exceptions from multiple runs
|
- Rendering execution history in a help/debug menu
|
||||||
- Embedding a diagnostic command into your CLI for user support
|
- Accessing previous results or errors for reuse
|
||||||
|
|
||||||
Note:
|
Attributes:
|
||||||
This registry is in-memory and not persistent. It's reset each time the process
|
_store_by_name (dict): Maps action name → list of ExecutionContext objects.
|
||||||
restarts or `clear()` is called.
|
_store_by_index (dict): Maps numeric index → ExecutionContext.
|
||||||
|
_store_all (list): Ordered list of all contexts.
|
||||||
Example:
|
_index (int): Global counter for assigning unique execution indices.
|
||||||
ExecutionRegistry.record(context)
|
_lock (Lock): Thread lock for atomic writes to the registry.
|
||||||
ExecutionRegistry.summary()
|
_console (Console): Rich console used for rendering summaries.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_store_by_name: Dict[str, List[ExecutionContext]] = defaultdict(list)
|
_store_by_name: dict[str, list[ExecutionContext]] = defaultdict(list)
|
||||||
_store_all: List[ExecutionContext] = []
|
_store_by_index: dict[int, ExecutionContext] = {}
|
||||||
_console = Console(color_system="auto")
|
_store_all: list[ExecutionContext] = []
|
||||||
|
_console: Console = console
|
||||||
|
_index = 0
|
||||||
|
_lock = Lock()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def record(cls, context: ExecutionContext):
|
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())
|
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_by_name[context.name].append(context)
|
||||||
cls._store_all.append(context)
|
cls._store_all.append(context)
|
||||||
|
|
||||||
@classmethod
|
@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
|
return cls._store_all
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_by_name(cls, name: str) -> List[ExecutionContext]:
|
def get_by_name(cls, name: str) -> list[ExecutionContext]:
|
||||||
|
"""Return 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, [])
|
return cls._store_by_name.get(name, [])
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_latest(cls) -> ExecutionContext:
|
def get_latest(cls) -> ExecutionContext:
|
||||||
|
"""Return the most recent execution context.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ExecutionContext: The last recorded context.
|
||||||
|
"""
|
||||||
return cls._store_all[-1]
|
return cls._store_all[-1]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def clear(cls):
|
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_by_name.clear()
|
||||||
cls._store_all.clear()
|
cls._store_all.clear()
|
||||||
|
cls._store_by_index.clear()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def summary(cls):
|
def summary(
|
||||||
table = Table(title="📊 Execution History", expand=True, box=box.SIMPLE)
|
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("Name", style="bold cyan")
|
||||||
table.add_column("Start", justify="right", style="dim")
|
table.add_column("Start", justify="right", style="dim")
|
||||||
table.add_column("End", justify="right", style="dim")
|
table.add_column("End", justify="right", style="dim")
|
||||||
@@ -109,7 +240,7 @@ class ExecutionRegistry:
|
|||||||
table.add_column("Status", style="bold")
|
table.add_column("Status", style="bold")
|
||||||
table.add_column("Result / Exception", overflow="fold")
|
table.add_column("Result / Exception", overflow="fold")
|
||||||
|
|
||||||
for ctx in cls.get_all():
|
for ctx in contexts:
|
||||||
start = (
|
start = (
|
||||||
datetime.fromtimestamp(ctx.start_time).strftime("%H:%M:%S")
|
datetime.fromtimestamp(ctx.start_time).strftime("%H:%M:%S")
|
||||||
if ctx.start_time
|
if ctx.start_time
|
||||||
@@ -122,15 +253,19 @@ class ExecutionRegistry:
|
|||||||
)
|
)
|
||||||
duration = f"{ctx.duration:.3f}s" if ctx.duration else "n/a"
|
duration = f"{ctx.duration:.3f}s" if ctx.duration else "n/a"
|
||||||
|
|
||||||
if ctx.exception:
|
if ctx.exception and status.lower() in ["all", "error"]:
|
||||||
status = f"[{OneColors.DARK_RED}]❌ Error"
|
final_status = f"[{OneColors.DARK_RED}]❌ Error"
|
||||||
result = repr(ctx.exception)
|
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:
|
else:
|
||||||
status = f"[{OneColors.GREEN}]✅ Success"
|
continue
|
||||||
result = repr(ctx.result)
|
|
||||||
if len(result) > 1000:
|
|
||||||
result = f"{result[:1000]}..."
|
|
||||||
|
|
||||||
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)
|
cls._console.print(table)
|
||||||
|
|||||||
3029
falyx/falyx.py
3029
falyx/falyx.py
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,20 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 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
|
from __future__ import annotations
|
||||||
|
|
||||||
import inspect
|
import inspect
|
||||||
@@ -15,7 +30,26 @@ Hook = Union[
|
|||||||
|
|
||||||
|
|
||||||
class HookType(Enum):
|
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"
|
BEFORE = "before"
|
||||||
ON_SUCCESS = "on_success"
|
ON_SUCCESS = "on_success"
|
||||||
@@ -28,13 +62,48 @@ class HookType(Enum):
|
|||||||
"""Return a list of all hook type choices."""
|
"""Return a list of all hook type choices."""
|
||||||
return list(cls)
|
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:
|
def __str__(self) -> str:
|
||||||
"""Return the string representation of the hook type."""
|
"""Return the string representation of the hook type."""
|
||||||
return self.value
|
return self.value
|
||||||
|
|
||||||
|
|
||||||
class HookManager:
|
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:
|
def __init__(self) -> None:
|
||||||
self._hooks: dict[HookType, list[Hook]] = {
|
self._hooks: dict[HookType, list[Hook]] = {
|
||||||
@@ -42,12 +111,24 @@ class HookManager:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def register(self, hook_type: HookType | str, hook: Hook):
|
def register(self, hook_type: HookType | str, hook: Hook):
|
||||||
"""Raises ValueError if the hook type is not supported."""
|
"""Register a new hook for a given lifecycle phase.
|
||||||
if not isinstance(hook_type, HookType):
|
|
||||||
hook_type = HookType(hook_type)
|
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)
|
self._hooks[hook_type].append(hook)
|
||||||
|
|
||||||
def clear(self, hook_type: HookType | None = 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:
|
if hook_type:
|
||||||
self._hooks[hook_type] = []
|
self._hooks[hook_type] = []
|
||||||
else:
|
else:
|
||||||
@@ -55,6 +136,16 @@ class HookManager:
|
|||||||
self._hooks[ht] = []
|
self._hooks[ht] = []
|
||||||
|
|
||||||
async def trigger(self, hook_type: HookType, context: ExecutionContext):
|
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:
|
if hook_type not in self._hooks:
|
||||||
raise ValueError(f"Unsupported hook type: {hook_type}")
|
raise ValueError(f"Unsupported hook type: {hook_type}")
|
||||||
for hook in self._hooks[hook_type]:
|
for hook in self._hooks[hook_type]:
|
||||||
@@ -65,13 +156,12 @@ class HookManager:
|
|||||||
hook(context)
|
hook(context)
|
||||||
except Exception as hook_error:
|
except Exception as hook_error:
|
||||||
logger.warning(
|
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.__name__,
|
||||||
hook_type,
|
hook_type,
|
||||||
context.name,
|
context.name,
|
||||||
hook_error,
|
hook_error,
|
||||||
)
|
)
|
||||||
|
|
||||||
if hook_type == HookType.ON_ERROR:
|
if hook_type == HookType.ON_ERROR:
|
||||||
assert isinstance(
|
assert isinstance(
|
||||||
context.exception, Exception
|
context.exception, Exception
|
||||||
|
|||||||
@@ -1,5 +1,31 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 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
|
import time
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
@@ -9,6 +35,38 @@ from falyx.logger import logger
|
|||||||
from falyx.themes import OneColors
|
from falyx.themes import OneColors
|
||||||
|
|
||||||
|
|
||||||
|
async def spinner_before_hook(context: ExecutionContext):
|
||||||
|
"""Adds a spinner before the action starts."""
|
||||||
|
command = context.action
|
||||||
|
if command.options_manager is None:
|
||||||
|
return
|
||||||
|
sm = context.action.options_manager.spinners
|
||||||
|
if hasattr(command, "name"):
|
||||||
|
command_name = command.name
|
||||||
|
else:
|
||||||
|
command_name = command.key
|
||||||
|
await sm.add(
|
||||||
|
command_name,
|
||||||
|
command.spinner_message,
|
||||||
|
command.spinner_type,
|
||||||
|
command.spinner_style,
|
||||||
|
command.spinner_speed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def spinner_teardown_hook(context: ExecutionContext):
|
||||||
|
"""Removes the spinner after the action finishes (success or failure)."""
|
||||||
|
command = context.action
|
||||||
|
if command.options_manager is None:
|
||||||
|
return
|
||||||
|
if hasattr(command, "name"):
|
||||||
|
command_name = command.name
|
||||||
|
else:
|
||||||
|
command_name = command.key
|
||||||
|
sm = context.action.options_manager.spinners
|
||||||
|
await sm.remove(command_name)
|
||||||
|
|
||||||
|
|
||||||
class ResultReporter:
|
class ResultReporter:
|
||||||
"""Reports the success of an action."""
|
"""Reports the success of an action."""
|
||||||
|
|
||||||
@@ -56,10 +114,10 @@ class CircuitBreaker:
|
|||||||
if self.open_until:
|
if self.open_until:
|
||||||
if time.time() < self.open_until:
|
if time.time() < self.open_until:
|
||||||
raise CircuitBreakerOpen(
|
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:
|
else:
|
||||||
logger.info("🟢 Circuit closed again for '%s'.")
|
logger.info("Circuit closed again for '%s'.")
|
||||||
self.failures = 0
|
self.failures = 0
|
||||||
self.open_until = None
|
self.open_until = None
|
||||||
|
|
||||||
@@ -67,7 +125,7 @@ class CircuitBreaker:
|
|||||||
name = context.name
|
name = context.name
|
||||||
self.failures += 1
|
self.failures += 1
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"⚠️ CircuitBreaker: '%s' failure %s/%s.",
|
"CircuitBreaker: '%s' failure %s/%s.",
|
||||||
name,
|
name,
|
||||||
self.failures,
|
self.failures,
|
||||||
self.max_failures,
|
self.max_failures,
|
||||||
@@ -75,7 +133,7 @@ class CircuitBreaker:
|
|||||||
if self.failures >= self.max_failures:
|
if self.failures >= self.max_failures:
|
||||||
self.open_until = time.time() + self.reset_timeout
|
self.open_until = time.time() + self.reset_timeout
|
||||||
logger.error(
|
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):
|
def after_hook(self, _: ExecutionContext):
|
||||||
@@ -87,4 +145,4 @@ class CircuitBreaker:
|
|||||||
def reset(self):
|
def reset(self):
|
||||||
self.failures = 0
|
self.failures = 0
|
||||||
self.open_until = None
|
self.open_until = None
|
||||||
logger.info("🔄 Circuit reset.")
|
logger.info("Circuit reset.")
|
||||||
|
|||||||
@@ -1,8 +1,25 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 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 pathlib import Path
|
||||||
|
|
||||||
from rich.console import Console
|
from falyx.console import console
|
||||||
|
|
||||||
TEMPLATE_TASKS = """\
|
TEMPLATE_TASKS = """\
|
||||||
# This file is used by falyx.yaml to define CLI actions.
|
# This file is used by falyx.yaml to define CLI actions.
|
||||||
@@ -11,9 +28,7 @@ TEMPLATE_TASKS = """\
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from falyx.action import Action, ChainedAction
|
from falyx.action import Action, ChainedAction, ShellAction, SelectionAction
|
||||||
from falyx.io_action import ShellAction
|
|
||||||
from falyx.selection_action import SelectionAction
|
|
||||||
|
|
||||||
|
|
||||||
post_ids = ["1", "2", "3", "4", "5"]
|
post_ids = ["1", "2", "3", "4", "5"]
|
||||||
@@ -100,10 +115,8 @@ commands:
|
|||||||
aliases: [clean, cleanup]
|
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 = Path(name).resolve()
|
||||||
target.mkdir(parents=True, exist_ok=True)
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""logger.py"""
|
"""Global logger instance for Falyx CLI applications."""
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger("falyx")
|
logger: logging.Logger = logging.getLogger("falyx")
|
||||||
|
|||||||
@@ -1,8 +1,26 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 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 __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
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.signals import BackSignal, QuitSignal
|
||||||
from falyx.themes import OneColors
|
from falyx.themes import OneColors
|
||||||
from falyx.utils import CaseInsensitiveDict
|
from falyx.utils import CaseInsensitiveDict
|
||||||
@@ -10,7 +28,25 @@ from falyx.utils import CaseInsensitiveDict
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class MenuOption:
|
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
|
description: str
|
||||||
action: BaseAction
|
action: BaseAction
|
||||||
@@ -26,11 +62,36 @@ class MenuOption:
|
|||||||
"""Render the menu option for display."""
|
"""Render the menu option for display."""
|
||||||
return f"[{OneColors.WHITE}][{key}][/] [{self.style}]{self.description}[/]"
|
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):
|
class MenuOptionMap(CaseInsensitiveDict):
|
||||||
"""
|
"""
|
||||||
Manages menu options including validation, reserved key protection,
|
A container for storing and managing `MenuOption` objects by key.
|
||||||
and special signal entries like Quit and Back.
|
|
||||||
|
`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 = {"B", "X"}
|
RESERVED_KEYS = {"B", "X"}
|
||||||
@@ -39,12 +100,16 @@ class MenuOptionMap(CaseInsensitiveDict):
|
|||||||
self,
|
self,
|
||||||
options: dict[str, MenuOption] | None = None,
|
options: dict[str, MenuOption] | None = None,
|
||||||
allow_reserved: bool = False,
|
allow_reserved: bool = False,
|
||||||
|
disable_reserved: bool = False,
|
||||||
):
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.allow_reserved = allow_reserved
|
self.allow_reserved = allow_reserved
|
||||||
if options:
|
if options:
|
||||||
self.update(options)
|
self.update(options)
|
||||||
self._inject_reserved_defaults()
|
if not disable_reserved:
|
||||||
|
self._inject_reserved_defaults()
|
||||||
|
else:
|
||||||
|
self.allow_reserved = True
|
||||||
|
|
||||||
def _inject_reserved_defaults(self):
|
def _inject_reserved_defaults(self):
|
||||||
from falyx.action import SignalAction
|
from falyx.action import SignalAction
|
||||||
|
|||||||
42
falyx/mode.py
Normal file
42
falyx/mode.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
|
"""Runtime mode definitions for the Falyx CLI framework.
|
||||||
|
|
||||||
|
This module defines `FalyxMode`, the enum used to represent the high-level
|
||||||
|
operating mode of a Falyx application during parsing, routing, rendering, and
|
||||||
|
execution.
|
||||||
|
|
||||||
|
These modes describe the current intent of the runtime rather than any
|
||||||
|
particular command. They are used throughout Falyx to coordinate behavior such
|
||||||
|
as whether the application should show an interactive menu, execute a routed
|
||||||
|
command, render help output, preview a command, or surface an error state.
|
||||||
|
|
||||||
|
`FalyxMode` is commonly stored in shared runtime state and passed through
|
||||||
|
invocation and parsing layers so UI rendering and execution flow remain
|
||||||
|
consistent across CLI and menu-driven entrypoints.
|
||||||
|
"""
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class FalyxMode(Enum):
|
||||||
|
"""Enumerates the high-level runtime modes used by Falyx.
|
||||||
|
|
||||||
|
`FalyxMode` provides a small set of application-wide states that describe
|
||||||
|
how the current invocation should be handled.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
MENU: Interactive menu mode using Prompt Toolkit input and menu
|
||||||
|
rendering.
|
||||||
|
COMMAND: Direct command-execution mode for routed CLI or programmatic
|
||||||
|
invocation.
|
||||||
|
PREVIEW: Non-executing preview mode used to inspect a command before it
|
||||||
|
runs.
|
||||||
|
HELP: Help-rendering mode for namespace, command, or TLDR output.
|
||||||
|
ERROR: Error state used when invocation handling should surface a
|
||||||
|
failure condition.
|
||||||
|
"""
|
||||||
|
|
||||||
|
MENU = "menu"
|
||||||
|
COMMAND = "command"
|
||||||
|
PREVIEW = "preview"
|
||||||
|
HELP = "help"
|
||||||
|
ERROR = "error"
|
||||||
68
falyx/namespace.py
Normal file
68
falyx/namespace.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
|
"""Namespace entry model for nested Falyx applications.
|
||||||
|
|
||||||
|
This module defines `FalyxNamespace`, the lightweight metadata container used to
|
||||||
|
register one `Falyx` instance inside another as a routed namespace entry.
|
||||||
|
|
||||||
|
A `FalyxNamespace` describes how a nested application should appear and behave
|
||||||
|
from the perspective of its parent namespace. It stores the public-facing key,
|
||||||
|
description, aliases, styling, and visibility flags used for routing,
|
||||||
|
completion, help rendering, and menu display, while holding a reference to the
|
||||||
|
child `Falyx` runtime that should take over once the namespace is entered.
|
||||||
|
|
||||||
|
This model is intentionally small and declarative. It does not implement
|
||||||
|
routing, rendering, or execution itself; those responsibilities remain with the
|
||||||
|
parent and child `Falyx` instances.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from rich.style import StyleType
|
||||||
|
|
||||||
|
from falyx.context import InvocationContext
|
||||||
|
from falyx.themes import OneColors
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from falyx.falyx import Falyx
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FalyxNamespace:
|
||||||
|
"""Represents a nested `Falyx` application exposed as a namespace entry.
|
||||||
|
|
||||||
|
`FalyxNamespace` is used by a parent `Falyx` instance to register and
|
||||||
|
describe a child `Falyx` runtime as a routable namespace. It provides the
|
||||||
|
metadata needed to expose that child namespace consistently across command
|
||||||
|
resolution, completion, help output, and menu rendering.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
key (str): Primary identifier used to enter the namespace.
|
||||||
|
description (str): User-facing namespace description.
|
||||||
|
namespace (Falyx): Nested `Falyx` instance activated when this namespace is
|
||||||
|
selected.
|
||||||
|
aliases (list[str]): Optional alternate names that may also resolve to the same
|
||||||
|
namespace.
|
||||||
|
help_text (str): Optional short help text used in listings or help output.
|
||||||
|
style (StyleType): Rich style used when rendering the namespace key or aliases.
|
||||||
|
hidden (bool): Whether the namespace should be omitted from visible menus and
|
||||||
|
help listings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
description: str
|
||||||
|
namespace: Falyx
|
||||||
|
aliases: list[str] = field(default_factory=list)
|
||||||
|
help_text: str = ""
|
||||||
|
style: StyleType = OneColors.CYAN
|
||||||
|
hidden: bool = False
|
||||||
|
|
||||||
|
def get_help_signature(
|
||||||
|
self, invocation_context: InvocationContext
|
||||||
|
) -> tuple[str, str, str | None]:
|
||||||
|
"""Returns the usage signature for this namespace, used in help rendering."""
|
||||||
|
usage = f"{self.key} {self.namespace._get_usage_fragment(invocation_context)}"
|
||||||
|
if self.aliases:
|
||||||
|
usage += f" (aliases: {', '.join(self.aliases)})"
|
||||||
|
return usage, self.description, self.help_text
|
||||||
@@ -1,43 +1,173 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""options_manager.py"""
|
"""Option state management for Falyx CLI runtimes.
|
||||||
|
|
||||||
from argparse import Namespace
|
This module defines `OptionsManager`, a small utility responsible for
|
||||||
|
storing, retrieving, and temporarily overriding runtime option values across
|
||||||
|
named namespaces.
|
||||||
|
|
||||||
|
Falyx uses this manager to hold global session- and execution-scoped flags such
|
||||||
|
as verbosity, prompt suppression, confirmation behavior, and other mutable
|
||||||
|
runtime settings. Options are stored in isolated namespace dictionaries so
|
||||||
|
different layers of the runtime can share one manager without clobbering each
|
||||||
|
other's state.
|
||||||
|
|
||||||
|
In addition to basic get/set operations, the manager provides helpers for:
|
||||||
|
|
||||||
|
- toggling boolean flags
|
||||||
|
- exposing option access as zero-argument callables for UI bindings
|
||||||
|
- temporarily overriding a namespace within a context manager
|
||||||
|
- holding a shared `SpinnerManager` for spinner lifecycle integration
|
||||||
|
|
||||||
|
Typical usage:
|
||||||
|
```
|
||||||
|
options = OptionsManager()
|
||||||
|
options.from_mapping({"verbose": True})
|
||||||
|
if options.get("verbose"):
|
||||||
|
...
|
||||||
|
|
||||||
|
with options.override_namespace({"skip_confirm": True}, "execution"):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
options (defaultdict[str, dict[str, Any]]): Mapping of namespace names to
|
||||||
|
option dictionaries.
|
||||||
|
spinners (SpinnerManager): Shared spinner manager available to runtime
|
||||||
|
components that need coordinated spinner rendering.
|
||||||
|
"""
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import Any, Callable
|
from contextlib import contextmanager
|
||||||
|
from typing import Any, Callable, Iterator, Mapping
|
||||||
|
|
||||||
from falyx.logger import logger
|
from falyx.logger import logger
|
||||||
|
from falyx.spinner_manager import SpinnerManager
|
||||||
|
|
||||||
|
|
||||||
class OptionsManager:
|
class OptionsManager:
|
||||||
"""OptionsManager"""
|
"""Manage mutable option values across named runtime namespaces.
|
||||||
|
|
||||||
def __init__(self, namespaces: list[tuple[str, Namespace]] | None = None) -> None:
|
`OptionsManager` is the central store for Falyx runtime flags. Each option
|
||||||
self.options: defaultdict = defaultdict(Namespace)
|
is stored under a namespace name such as `"default"` or `"execution"`,
|
||||||
|
allowing global settings and temporary execution-scoped overrides to
|
||||||
|
coexist in one shared object.
|
||||||
|
|
||||||
|
The manager supports direct reads and writes, boolean toggling, namespace
|
||||||
|
snapshots, and temporary override contexts. It also exposes small callable
|
||||||
|
wrappers that are useful when integrating option reads or toggles into UI
|
||||||
|
components such as bottom-bar controls or key bindings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
namespaces (list[tuple[str, dict[str, Any]]] | None): Optional initial
|
||||||
|
namespace/value pairs to preload into the manager.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
options (defaultdict[str, dict[str, Any]]): Internal namespace-to-option
|
||||||
|
mapping.
|
||||||
|
spinners (SpinnerManager): Shared spinner manager used by other Falyx
|
||||||
|
runtime components.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
namespaces: list[tuple[str, dict[str, Any]]] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the option manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
namespaces (list[tuple[str, dict[str, Any]]] | None): Optional list
|
||||||
|
of `(namespace_name, values)` pairs to load during
|
||||||
|
initialization.
|
||||||
|
"""
|
||||||
|
self.options: defaultdict = defaultdict(dict)
|
||||||
|
self.spinners = SpinnerManager()
|
||||||
if namespaces:
|
if namespaces:
|
||||||
for namespace_name, namespace in namespaces:
|
for namespace_name, namespace in namespaces:
|
||||||
self.from_namespace(namespace, namespace_name)
|
self.from_mapping(namespace, namespace_name)
|
||||||
|
|
||||||
def from_namespace(
|
def from_mapping(
|
||||||
self, namespace: Namespace, namespace_name: str = "cli_args"
|
self,
|
||||||
|
values: Mapping[str, Any],
|
||||||
|
namespace_name: str = "default",
|
||||||
) -> None:
|
) -> None:
|
||||||
self.options[namespace_name] = namespace
|
"""Merge option values into a namespace.
|
||||||
|
|
||||||
|
Existing keys in the target namespace are updated in place. Missing
|
||||||
|
namespaces are created automatically.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
values (Mapping[str, Any]): Mapping of option names to values.
|
||||||
|
namespace_name (str): Target namespace to update. Defaults to
|
||||||
|
`"default"`.
|
||||||
|
"""
|
||||||
|
self.options[namespace_name].update(dict(values))
|
||||||
|
|
||||||
def get(
|
def get(
|
||||||
self, option_name: str, default: Any = None, namespace_name: str = "cli_args"
|
self,
|
||||||
|
option_name: str,
|
||||||
|
default: Any = None,
|
||||||
|
namespace_name: str = "default",
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Get the value of an option."""
|
"""Return an option value from a namespace.
|
||||||
return getattr(self.options[namespace_name], option_name, default)
|
|
||||||
|
|
||||||
def set(self, option_name: str, value: Any, namespace_name: str = "cli_args") -> None:
|
Args:
|
||||||
"""Set the value of an option."""
|
option_name (str): Name of the option to retrieve.
|
||||||
setattr(self.options[namespace_name], option_name, value)
|
default (Any): Value to return when the option is not present.
|
||||||
|
Defaults to `None`.
|
||||||
|
namespace_name (str): Namespace to read from. Defaults to
|
||||||
|
`"default"`.
|
||||||
|
|
||||||
def has_option(self, option_name: str, namespace_name: str = "cli_args") -> bool:
|
Returns:
|
||||||
"""Check if an option exists in the namespace."""
|
Any: The stored option value if present, otherwise `default`.
|
||||||
return hasattr(self.options[namespace_name], option_name)
|
"""
|
||||||
|
return self.options[namespace_name].get(option_name, default)
|
||||||
|
|
||||||
def toggle(self, option_name: str, namespace_name: str = "cli_args") -> None:
|
def set(
|
||||||
"""Toggle a boolean option."""
|
self,
|
||||||
|
option_name: str,
|
||||||
|
value: Any,
|
||||||
|
namespace_name: str = "default",
|
||||||
|
) -> None:
|
||||||
|
"""Store an option value in a namespace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
option_name (str): Name of the option to set.
|
||||||
|
value (Any): Value to store.
|
||||||
|
namespace_name (str): Namespace to update. Defaults to `"default"`.
|
||||||
|
"""
|
||||||
|
self.options[namespace_name][option_name] = value
|
||||||
|
|
||||||
|
def has_option(
|
||||||
|
self,
|
||||||
|
option_name: str,
|
||||||
|
namespace_name: str = "default",
|
||||||
|
) -> bool:
|
||||||
|
"""Return whether an option exists in a namespace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
option_name (str): Name of the option to check.
|
||||||
|
namespace_name (str): Namespace to inspect. Defaults to `"default"`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: `True` if the option exists in the namespace, otherwise
|
||||||
|
`False`.
|
||||||
|
"""
|
||||||
|
return option_name in self.options[namespace_name]
|
||||||
|
|
||||||
|
def toggle(
|
||||||
|
self,
|
||||||
|
option_name: str,
|
||||||
|
namespace_name: str = "default",
|
||||||
|
) -> None:
|
||||||
|
"""Invert a boolean option in place.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
option_name (str): Name of the option to toggle.
|
||||||
|
namespace_name (str): Namespace containing the option. Defaults to
|
||||||
|
`"default"`.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError: If the target option is missing or is not a boolean.
|
||||||
|
"""
|
||||||
current = self.get(option_name, namespace_name=namespace_name)
|
current = self.get(option_name, namespace_name=namespace_name)
|
||||||
if not isinstance(current, bool):
|
if not isinstance(current, bool):
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
@@ -49,9 +179,24 @@ class OptionsManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_value_getter(
|
def get_value_getter(
|
||||||
self, option_name: str, namespace_name: str = "cli_args"
|
self,
|
||||||
|
option_name: str,
|
||||||
|
namespace_name: str = "default",
|
||||||
) -> Callable[[], Any]:
|
) -> Callable[[], Any]:
|
||||||
"""Get the value of an option as a getter function."""
|
"""Return a zero-argument callable that reads an option value.
|
||||||
|
|
||||||
|
This is useful for UI integrations that expect a callback instead of an
|
||||||
|
eagerly evaluated value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
option_name (str): Name of the option to read.
|
||||||
|
namespace_name (str): Namespace to read from. Defaults to
|
||||||
|
`"default"`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Callable[[], Any]: Function that returns the current option value
|
||||||
|
when called.
|
||||||
|
"""
|
||||||
|
|
||||||
def _getter() -> Any:
|
def _getter() -> Any:
|
||||||
return self.get(option_name, namespace_name=namespace_name)
|
return self.get(option_name, namespace_name=namespace_name)
|
||||||
@@ -59,17 +204,72 @@ class OptionsManager:
|
|||||||
return _getter
|
return _getter
|
||||||
|
|
||||||
def get_toggle_function(
|
def get_toggle_function(
|
||||||
self, option_name: str, namespace_name: str = "cli_args"
|
self,
|
||||||
|
option_name: str,
|
||||||
|
namespace_name: str = "default",
|
||||||
) -> Callable[[], None]:
|
) -> Callable[[], None]:
|
||||||
"""Get the toggle function for a boolean option."""
|
"""Return a zero-argument callable that toggles a boolean option.
|
||||||
|
|
||||||
|
This is useful for key bindings, bottom-bar toggles, or other UI hooks
|
||||||
|
that need a callable action.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
option_name (str): Name of the boolean option to toggle.
|
||||||
|
namespace_name (str): Namespace containing the option. Defaults to
|
||||||
|
`"default"`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Callable[[], None]: Function that toggles the option when called.
|
||||||
|
"""
|
||||||
|
|
||||||
def _toggle() -> None:
|
def _toggle() -> None:
|
||||||
self.toggle(option_name, namespace_name=namespace_name)
|
self.toggle(option_name, namespace_name=namespace_name)
|
||||||
|
|
||||||
return _toggle
|
return _toggle
|
||||||
|
|
||||||
def get_namespace_dict(self, namespace_name: str) -> Namespace:
|
def get_namespace_dict(self, namespace_name: str) -> dict[str, Any]:
|
||||||
"""Return all options in a namespace as a dictionary."""
|
"""Return a shallow copy of one namespace's option dictionary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
namespace_name (str): Namespace to snapshot.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Any]: Copy of the namespace's stored options.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the requested namespace does not exist.
|
||||||
|
"""
|
||||||
if namespace_name not in self.options:
|
if namespace_name not in self.options:
|
||||||
raise ValueError(f"Namespace '{namespace_name}' not found.")
|
raise ValueError(f"Namespace '{namespace_name}' not found.")
|
||||||
return vars(self.options[namespace_name])
|
return dict(self.options[namespace_name])
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def override_namespace(
|
||||||
|
self,
|
||||||
|
overrides: Mapping[str, Any],
|
||||||
|
namespace_name: str = "execution",
|
||||||
|
) -> Iterator[None]:
|
||||||
|
"""Temporarily apply option overrides within a namespace.
|
||||||
|
|
||||||
|
The current namespace contents are copied before the overrides are
|
||||||
|
applied. When the context exits, the original namespace state is
|
||||||
|
restored, even if an exception is raised inside the context block.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
overrides (Mapping[str, Any]): Temporary option values to merge into
|
||||||
|
the namespace.
|
||||||
|
namespace_name (str): Namespace to override. Defaults to
|
||||||
|
`"execution"`.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
None: Control is yielded to the wrapped context block.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the namespace does not already exist.
|
||||||
|
"""
|
||||||
|
original = self.get_namespace_dict(namespace_name)
|
||||||
|
try:
|
||||||
|
self.from_mapping(values=overrides, namespace_name=namespace_name)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
self.options[namespace_name] = original
|
||||||
|
|||||||
19
falyx/parser/__init__.py
Normal file
19
falyx/parser/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"""Falyx CLI Framework
|
||||||
|
|
||||||
|
Copyright (c) 2026 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 .falyx_parser import FalyxParser
|
||||||
|
from .parse_result import ParseResult
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Argument",
|
||||||
|
"ArgumentAction",
|
||||||
|
"CommandArgumentParser",
|
||||||
|
"FalyxParser",
|
||||||
|
"ParseResult",
|
||||||
|
]
|
||||||
157
falyx/parser/argument.py
Normal file
157
falyx/parser/argument.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 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
|
||||||
|
group (str | None): Optional name of the argument group this belongs to.
|
||||||
|
mutex_group (str | None): Optional name of the mutually exclusive group this belongs to.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
|
group: str | None = None
|
||||||
|
mutex_group: 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
|
||||||
|
and self.group == other.group
|
||||||
|
and self.mutex_group == other.mutex_group
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
self.group,
|
||||||
|
self.mutex_group,
|
||||||
|
)
|
||||||
|
)
|
||||||
92
falyx/parser/argument_action.py
Normal file
92
falyx/parser/argument_action.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 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
|
||||||
2324
falyx/parser/command_argument_parser.py
Normal file
2324
falyx/parser/command_argument_parser.py
Normal file
File diff suppressed because it is too large
Load Diff
650
falyx/parser/falyx_parser.py
Normal file
650
falyx/parser/falyx_parser.py
Normal file
@@ -0,0 +1,650 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from falyx.console import console
|
||||||
|
from falyx.exceptions import EntryNotFoundError, FalyxOptionError
|
||||||
|
from falyx.mode import FalyxMode
|
||||||
|
from falyx.options_manager import OptionsManager
|
||||||
|
from falyx.parser.parse_result import ParseResult
|
||||||
|
from falyx.parser.parser_types import (
|
||||||
|
FalyxTLDRExample,
|
||||||
|
FalyxTLDRInput,
|
||||||
|
false_none,
|
||||||
|
true_none,
|
||||||
|
)
|
||||||
|
from falyx.parser.utils import coerce_value, get_type_name
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from falyx.falyx import Falyx
|
||||||
|
|
||||||
|
builtin_type = type
|
||||||
|
|
||||||
|
|
||||||
|
class OptionAction(Enum):
|
||||||
|
STORE = "store"
|
||||||
|
STORE_TRUE = "store_true"
|
||||||
|
STORE_FALSE = "store_false"
|
||||||
|
STORE_BOOL_OPTIONAL = "store_bool_optional"
|
||||||
|
COUNT = "count"
|
||||||
|
HELP = "help"
|
||||||
|
TLDR = "tldr"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def choices(cls) -> list[OptionAction]:
|
||||||
|
"""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) -> OptionAction:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class OptionScope(Enum):
|
||||||
|
ROOT = "root"
|
||||||
|
NAMESPACE = "namespace"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _missing_(cls, value: object) -> OptionScope:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(f"Invalid {cls.__name__}: {value!r}")
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
for member in cls:
|
||||||
|
if member.value == normalized:
|
||||||
|
return member
|
||||||
|
valid = ", ".join(member.value for member in cls)
|
||||||
|
raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Option:
|
||||||
|
flags: tuple[str, ...]
|
||||||
|
dest: str
|
||||||
|
action: OptionAction = OptionAction.STORE
|
||||||
|
type: Any = str
|
||||||
|
default: Any = None
|
||||||
|
choices: list[str] | None = None
|
||||||
|
help: str = ""
|
||||||
|
suggestions: list[str] | None = None
|
||||||
|
scope: OptionScope = OptionScope.NAMESPACE
|
||||||
|
|
||||||
|
def format_for_help(self) -> str:
|
||||||
|
"""Return a formatted string of the option's flags for help output."""
|
||||||
|
return ", ".join(self.flags)
|
||||||
|
|
||||||
|
|
||||||
|
class FalyxParser:
|
||||||
|
RESERVED_DESTS: set[str] = {"help", "tldr"}
|
||||||
|
|
||||||
|
def __init__(self, flx: Falyx) -> None:
|
||||||
|
self._flx = flx
|
||||||
|
self._options_by_dest: dict[str, Option] = {}
|
||||||
|
self._options: list[Option] = []
|
||||||
|
self._dest_set: set[str] = set()
|
||||||
|
self._tldr_examples: list[FalyxTLDRExample] = []
|
||||||
|
self._add_reserved_options()
|
||||||
|
self.help_option: Option | None = None
|
||||||
|
self.tldr_option: Option | None = None
|
||||||
|
|
||||||
|
def get_flags(self) -> list[str]:
|
||||||
|
"""Return a list of the first flag for the registered options."""
|
||||||
|
return [option.flags[0] for option in self._options]
|
||||||
|
|
||||||
|
def get_options(self) -> list[Option]:
|
||||||
|
"""Return a list of registered options."""
|
||||||
|
return self._options
|
||||||
|
|
||||||
|
def _add_tldr(self):
|
||||||
|
"""Add TLDR argument to the parser."""
|
||||||
|
if "tldr" in self._dest_set:
|
||||||
|
return None
|
||||||
|
tldr = Option(
|
||||||
|
flags=("--tldr", "-T"),
|
||||||
|
action=OptionAction.TLDR,
|
||||||
|
help="Show quick usage examples.",
|
||||||
|
dest="tldr",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
self._register_option(tldr)
|
||||||
|
self.tldr_option = tldr
|
||||||
|
|
||||||
|
def add_tldr_example(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
entry_key: str,
|
||||||
|
usage: str,
|
||||||
|
description: str,
|
||||||
|
) -> None:
|
||||||
|
"""Register a single namespace-level TLDR example.
|
||||||
|
|
||||||
|
The referenced entry must resolve to a known command or namespace in the
|
||||||
|
current `Falyx` instance. Unknown entries are reported to the console and
|
||||||
|
are not added.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entry_key (str): Command or namespace key the example is associated with.
|
||||||
|
usage (str): Example usage fragment shown after the resolved invocation path.
|
||||||
|
description (str): Short explanation displayed alongside the example.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
EntryNotFoundError: If `entry_key` cannot be resolved to a known command or
|
||||||
|
namespace in this `Falyx` instance.
|
||||||
|
"""
|
||||||
|
entry, suggestions = self._flx.resolve_entry(entry_key)
|
||||||
|
if not entry:
|
||||||
|
raise EntryNotFoundError(
|
||||||
|
unknown_name=entry_key,
|
||||||
|
suggestions=suggestions,
|
||||||
|
message_context="TLDR example",
|
||||||
|
)
|
||||||
|
self._tldr_examples.append(
|
||||||
|
FalyxTLDRExample(entry_key=entry_key, usage=usage, description=description)
|
||||||
|
)
|
||||||
|
self._add_tldr()
|
||||||
|
|
||||||
|
def add_tldr_examples(self, examples: list[FalyxTLDRInput]) -> None:
|
||||||
|
"""Register multiple namespace-level TLDR examples.
|
||||||
|
|
||||||
|
Supports either `FalyxTLDRExample` objects or shorthand tuples of
|
||||||
|
`(entry_key, usage, description)`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
examples (list[FalyxTLDRInput]): Example definitions to validate and append.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FalyxError: If an example has an unsupported shape.
|
||||||
|
EntryNotFoundError: If `entry_key` cannot be resolved to a known command or
|
||||||
|
namespace in this `Falyx` instance.
|
||||||
|
"""
|
||||||
|
for example in examples:
|
||||||
|
if isinstance(example, FalyxTLDRExample):
|
||||||
|
entry, suggestions = self._flx.resolve_entry(example.entry_key)
|
||||||
|
if not entry:
|
||||||
|
raise EntryNotFoundError(
|
||||||
|
unknown_name=example.entry_key,
|
||||||
|
suggestions=suggestions,
|
||||||
|
message_context="TLDR example",
|
||||||
|
)
|
||||||
|
self._tldr_examples.append(example)
|
||||||
|
self._add_tldr()
|
||||||
|
elif len(example) == 3:
|
||||||
|
entry_key, usage, description = example
|
||||||
|
self.add_tldr_example(
|
||||||
|
entry_key=entry_key,
|
||||||
|
usage=usage,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
self._add_tldr()
|
||||||
|
else:
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"invalid TLDR example format: {example}.\n"
|
||||||
|
"examples must be either FalyxTLDRExample instances "
|
||||||
|
"or tuples of (entry_key, usage, description).",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _add_reserved_options(self) -> None:
|
||||||
|
help = Option(
|
||||||
|
flags=("-h", "--help", "?"),
|
||||||
|
dest="help",
|
||||||
|
action=OptionAction.HELP,
|
||||||
|
help="Show root-level help output and exit.",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
self._register_option(help)
|
||||||
|
self.help_option = help
|
||||||
|
|
||||||
|
if not self._flx.disable_verbose_option:
|
||||||
|
verbose = Option(
|
||||||
|
flags=("-v", "--verbose"),
|
||||||
|
dest="verbose",
|
||||||
|
action=OptionAction.STORE_TRUE,
|
||||||
|
help="Enable verbose logging for the session.",
|
||||||
|
default=False,
|
||||||
|
scope=OptionScope.ROOT,
|
||||||
|
)
|
||||||
|
self._register_option(verbose)
|
||||||
|
|
||||||
|
if not self._flx.disable_debug_hooks_option:
|
||||||
|
debug_hooks = Option(
|
||||||
|
flags=("-d", "--debug-hooks"),
|
||||||
|
dest="debug_hooks",
|
||||||
|
action=OptionAction.STORE_TRUE,
|
||||||
|
help="Log hook execution in detail for the session.",
|
||||||
|
default=False,
|
||||||
|
scope=OptionScope.ROOT,
|
||||||
|
)
|
||||||
|
self._register_option(debug_hooks)
|
||||||
|
|
||||||
|
if not self._flx.disable_never_prompt_option:
|
||||||
|
never_prompt = Option(
|
||||||
|
flags=("-n", "--never-prompt"),
|
||||||
|
dest="never_prompt",
|
||||||
|
action=OptionAction.STORE_TRUE,
|
||||||
|
help="Suppress all prompts for the session.",
|
||||||
|
default=False,
|
||||||
|
scope=OptionScope.ROOT,
|
||||||
|
)
|
||||||
|
self._register_option(never_prompt)
|
||||||
|
|
||||||
|
def _register_store_bool_optional(
|
||||||
|
self,
|
||||||
|
flags: tuple[str, ...],
|
||||||
|
dest: str,
|
||||||
|
help: str,
|
||||||
|
) -> None:
|
||||||
|
"""Register a store_bool_optional action with the parser."""
|
||||||
|
if len(flags) != 1:
|
||||||
|
raise FalyxOptionError(
|
||||||
|
"store_bool_optional action can only have a single flag"
|
||||||
|
)
|
||||||
|
if not flags[0].startswith("--"):
|
||||||
|
raise FalyxOptionError(
|
||||||
|
"store_bool_optional action must use a long flag (e.g. --flag)"
|
||||||
|
)
|
||||||
|
base_flag = flags[0]
|
||||||
|
negated_flag = f"--no-{base_flag.lstrip('-')}"
|
||||||
|
|
||||||
|
argument = Option(
|
||||||
|
flags=flags,
|
||||||
|
dest=dest,
|
||||||
|
action=OptionAction.STORE_BOOL_OPTIONAL,
|
||||||
|
type=true_none,
|
||||||
|
default=None,
|
||||||
|
help=help,
|
||||||
|
)
|
||||||
|
|
||||||
|
negated_argument = Option(
|
||||||
|
flags=(negated_flag,),
|
||||||
|
dest=dest,
|
||||||
|
action=OptionAction.STORE_BOOL_OPTIONAL,
|
||||||
|
type=false_none,
|
||||||
|
default=None,
|
||||||
|
help=help,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._register_option(argument)
|
||||||
|
self._register_option(negated_argument, bypass_validation=True)
|
||||||
|
|
||||||
|
def _register_option(self, option: Option, bypass_validation: bool = False) -> None:
|
||||||
|
self._dest_set.add(option.dest)
|
||||||
|
self._options.append(option)
|
||||||
|
for flag in option.flags:
|
||||||
|
if flag in self._options and not bypass_validation:
|
||||||
|
existing = self._options_by_dest[flag]
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"flag '{flag}' is already used by argument '{existing.dest}'"
|
||||||
|
)
|
||||||
|
self._options_by_dest[flag] = option
|
||||||
|
|
||||||
|
def _validate_flags(self, flags: tuple[str, ...]) -> None:
|
||||||
|
if not flags:
|
||||||
|
raise FalyxOptionError("no flags provided for option")
|
||||||
|
for flag in flags:
|
||||||
|
if not isinstance(flag, str):
|
||||||
|
raise FalyxOptionError(f"invalid flag '{flag}': must be a string")
|
||||||
|
if not flag.startswith("-"):
|
||||||
|
raise FalyxOptionError(f"invalid flag '{flag}': must start with '-'")
|
||||||
|
if flag.startswith("--") and len(flag) < 3:
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"invalid flag '{flag}': long flags must have at least one character after '--'"
|
||||||
|
)
|
||||||
|
if flag.startswith("-") and not flag.startswith("--") and len(flag) > 2:
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"invalid flag '{flag}': short flags must be a single character"
|
||||||
|
)
|
||||||
|
if flag in self._options_by_dest:
|
||||||
|
existing = self._options_by_dest[flag]
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"flag '{flag}' is already used by argument '{existing.dest}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_dest_from_flags(self, flags: tuple[str, ...], dest: str | None) -> str:
|
||||||
|
if dest:
|
||||||
|
if not dest.replace("_", "").isalnum():
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"invalid dest '{dest}': must be a valid identifier (letters, digits, and underscores only)"
|
||||||
|
)
|
||||||
|
if dest[0].isdigit():
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"invalid dest '{dest}': cannot start with a digit"
|
||||||
|
)
|
||||||
|
return dest
|
||||||
|
dest = None
|
||||||
|
for flag in flags:
|
||||||
|
cleaned = flag.lstrip("-").replace("-", "_").lower()
|
||||||
|
dest = cleaned
|
||||||
|
if flag.startswith("--"):
|
||||||
|
break
|
||||||
|
assert dest is not None, "dest should not be None"
|
||||||
|
if not dest.replace("_", "").isalnum():
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"invalid dest '{dest}': must be a valid identifier (letters, digits, and underscores only)"
|
||||||
|
)
|
||||||
|
if dest[0].isdigit():
|
||||||
|
raise FalyxOptionError(f"invalid dest '{dest}': cannot start with a digit")
|
||||||
|
return dest
|
||||||
|
|
||||||
|
def _validate_action(self, action: str | OptionAction) -> OptionAction:
|
||||||
|
if isinstance(action, OptionAction):
|
||||||
|
return action
|
||||||
|
try:
|
||||||
|
return OptionAction(action)
|
||||||
|
except ValueError as error:
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"invalid option action '{action}' is not a valid OptionAction",
|
||||||
|
hint=f"valid actions are: {', '.join(a.value for a in OptionAction)}",
|
||||||
|
) from error
|
||||||
|
|
||||||
|
def _resolve_default(
|
||||||
|
self,
|
||||||
|
default: Any,
|
||||||
|
action: OptionAction,
|
||||||
|
) -> Any:
|
||||||
|
if default is None:
|
||||||
|
if action == OptionAction.STORE_TRUE:
|
||||||
|
return False
|
||||||
|
elif action == OptionAction.STORE_FALSE:
|
||||||
|
return True
|
||||||
|
elif action == OptionAction.STORE_BOOL_OPTIONAL:
|
||||||
|
return None
|
||||||
|
elif action == OptionAction.COUNT:
|
||||||
|
return 0
|
||||||
|
elif action is OptionAction.STORE_TRUE and default is not False:
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"default value for '{action}' action must be False or None, got {default!r}"
|
||||||
|
)
|
||||||
|
elif action is OptionAction.STORE_FALSE and default is not True:
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"default value for '{action}' action must be True or None, got {default!r}"
|
||||||
|
)
|
||||||
|
elif action is OptionAction.STORE_BOOL_OPTIONAL:
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"default value for '{action}' action must be None, got {default!r}"
|
||||||
|
)
|
||||||
|
elif action in (OptionAction.HELP, OptionAction.TLDR, OptionAction.COUNT):
|
||||||
|
raise FalyxOptionError(f"default value cannot be set for action '{action}'.")
|
||||||
|
return default
|
||||||
|
|
||||||
|
def _validate_default_type(
|
||||||
|
self,
|
||||||
|
default: Any,
|
||||||
|
expected_type: Any,
|
||||||
|
dest: str,
|
||||||
|
) -> None:
|
||||||
|
if default is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
coerce_value(default, expected_type)
|
||||||
|
except Exception as error:
|
||||||
|
type_name = get_type_name(expected_type)
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"invalid default value {default!r} for '{dest}' cannot be coerced to {type_name} error: {error}"
|
||||||
|
) from error
|
||||||
|
|
||||||
|
def _normalize_choices(
|
||||||
|
self,
|
||||||
|
choices: list[str] | None,
|
||||||
|
expected_type: type,
|
||||||
|
action: OptionAction,
|
||||||
|
) -> list[Any]:
|
||||||
|
if choices is None:
|
||||||
|
choices = []
|
||||||
|
else:
|
||||||
|
if action in (
|
||||||
|
OptionAction.STORE_TRUE,
|
||||||
|
OptionAction.STORE_FALSE,
|
||||||
|
OptionAction.STORE_BOOL_OPTIONAL,
|
||||||
|
):
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"choices cannot be specified for '{action}' actions"
|
||||||
|
)
|
||||||
|
if isinstance(choices, dict):
|
||||||
|
raise FalyxOptionError("choices cannot be a dict")
|
||||||
|
try:
|
||||||
|
choices = list(choices)
|
||||||
|
except TypeError as error:
|
||||||
|
raise FalyxOptionError(
|
||||||
|
"choices must be iterable (like list, tuple, or set)"
|
||||||
|
) from error
|
||||||
|
for choice in choices:
|
||||||
|
try:
|
||||||
|
coerce_value(choice, expected_type)
|
||||||
|
except Exception as error:
|
||||||
|
type_name = get_type_name(expected_type)
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"invalid choice {choice!r} cannot be coerced to {type_name} error: {error}"
|
||||||
|
) from error
|
||||||
|
return choices
|
||||||
|
|
||||||
|
def add_option(
|
||||||
|
self,
|
||||||
|
flags: tuple[str, ...],
|
||||||
|
dest: str,
|
||||||
|
action: str | OptionAction = "store",
|
||||||
|
type: type = str,
|
||||||
|
default: Any = None,
|
||||||
|
choices: list[str] | None = None,
|
||||||
|
help: str = "",
|
||||||
|
suggestions: list[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._validate_flags(flags)
|
||||||
|
dest = self._get_dest_from_flags(flags, dest)
|
||||||
|
if dest in self.RESERVED_DESTS:
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"invalid dest '{dest}': '{dest}' is reserved and cannot be used as an option dest"
|
||||||
|
)
|
||||||
|
if dest in self._dest_set:
|
||||||
|
raise FalyxOptionError(f"duplicate option dest '{dest}'")
|
||||||
|
action = self._validate_action(action)
|
||||||
|
default = self._resolve_default(default, action)
|
||||||
|
self._validate_default_type(default, type, dest)
|
||||||
|
choices = self._normalize_choices(choices, type, action)
|
||||||
|
if default is not None and choices and default not in choices:
|
||||||
|
choices_str = ", ".join((str(choice) for choice in choices))
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"default value {default!r} is not in allowed choices: {choices_str}"
|
||||||
|
)
|
||||||
|
if suggestions is not None and not isinstance(suggestions, list):
|
||||||
|
type_name = get_type_name(suggestions)
|
||||||
|
raise FalyxOptionError(f"suggestions must be a list or None, got {type_name}")
|
||||||
|
if isinstance(suggestions, list) and not all(
|
||||||
|
isinstance(suggestion, str) for suggestion in suggestions
|
||||||
|
):
|
||||||
|
raise FalyxOptionError("suggestions must be a list of strings")
|
||||||
|
if action is OptionAction.STORE_BOOL_OPTIONAL:
|
||||||
|
self._register_store_bool_optional(flags, dest, help)
|
||||||
|
return None
|
||||||
|
option = Option(
|
||||||
|
flags=flags,
|
||||||
|
dest=dest,
|
||||||
|
action=action,
|
||||||
|
type=type,
|
||||||
|
default=default,
|
||||||
|
choices=choices,
|
||||||
|
help=help,
|
||||||
|
suggestions=suggestions,
|
||||||
|
)
|
||||||
|
self._register_option(option)
|
||||||
|
|
||||||
|
def apply_to_options(
|
||||||
|
self,
|
||||||
|
parse_result: ParseResult,
|
||||||
|
options: OptionsManager,
|
||||||
|
) -> None:
|
||||||
|
for dest, value in parse_result.options.items():
|
||||||
|
options.set(dest, value, namespace_name=self_flx.namespace_name)
|
||||||
|
for dest, value in parse_result.root_options.items():
|
||||||
|
options.set(dest, value, namespace_name="root")
|
||||||
|
|
||||||
|
def _can_bundle_option(self, option: Option) -> bool:
|
||||||
|
return option.action in {
|
||||||
|
OptionAction.STORE_TRUE,
|
||||||
|
OptionAction.STORE_FALSE,
|
||||||
|
OptionAction.COUNT,
|
||||||
|
OptionAction.HELP,
|
||||||
|
OptionAction.TLDR,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _resolve_posix_bundling(self, tokens: list[str]) -> list[str]:
|
||||||
|
"""Expand POSIX-style bundled arguments into separate arguments."""
|
||||||
|
expanded: list[str] = []
|
||||||
|
for token in tokens:
|
||||||
|
if not token.startswith("-") or token.startswith("--") or len(token) <= 2:
|
||||||
|
expanded.append(token)
|
||||||
|
continue
|
||||||
|
|
||||||
|
bundle = [f"-{char}" for char in token[1:]]
|
||||||
|
|
||||||
|
if (
|
||||||
|
all(
|
||||||
|
flag in self._options_by_dest
|
||||||
|
and self._can_bundle_option(self._options_by_dest[flag])
|
||||||
|
for flag in bundle[:-1]
|
||||||
|
)
|
||||||
|
and bundle[-1] in self._options_by_dest
|
||||||
|
):
|
||||||
|
expanded.extend(bundle)
|
||||||
|
else:
|
||||||
|
expanded.append(token)
|
||||||
|
return expanded
|
||||||
|
|
||||||
|
def _default_values(self) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
|
values: dict[str, Any] = {}
|
||||||
|
root_values: dict[str, Any] = {}
|
||||||
|
|
||||||
|
for option in self._options:
|
||||||
|
if option.scope == OptionScope.ROOT:
|
||||||
|
root_values[option.dest] = option.default
|
||||||
|
elif option.scope == OptionScope.NAMESPACE:
|
||||||
|
values.setdefault(option.dest, option.default)
|
||||||
|
else:
|
||||||
|
assert False, f"unhandled option scope: {option.scope}"
|
||||||
|
|
||||||
|
return values, root_values
|
||||||
|
|
||||||
|
def _consume_option(
|
||||||
|
self,
|
||||||
|
option: Option,
|
||||||
|
argv: list[str],
|
||||||
|
index: int,
|
||||||
|
values: dict[str, Any],
|
||||||
|
) -> int:
|
||||||
|
match option.action:
|
||||||
|
case OptionAction.STORE_TRUE:
|
||||||
|
values[option.dest] = True
|
||||||
|
return index + 1
|
||||||
|
|
||||||
|
case OptionAction.STORE_FALSE:
|
||||||
|
values[option.dest] = False
|
||||||
|
return index + 1
|
||||||
|
|
||||||
|
case OptionAction.STORE_BOOL_OPTIONAL:
|
||||||
|
values[option.dest] = option.type(None)
|
||||||
|
return index + 1
|
||||||
|
|
||||||
|
case OptionAction.COUNT:
|
||||||
|
values[option.dest] = int(values.get(option.dest) or 0) + 1
|
||||||
|
return index + 1
|
||||||
|
|
||||||
|
case OptionAction.HELP:
|
||||||
|
values[option.dest] = True
|
||||||
|
return index + 1
|
||||||
|
|
||||||
|
case OptionAction.TLDR:
|
||||||
|
values[option.dest] = True
|
||||||
|
return index + 1
|
||||||
|
|
||||||
|
case OptionAction.STORE:
|
||||||
|
value_index = index + 1
|
||||||
|
if value_index >= len(argv):
|
||||||
|
raise FalyxOptionError(f"option '{argv[index]}' expected a value")
|
||||||
|
|
||||||
|
raw_value = argv[value_index]
|
||||||
|
try:
|
||||||
|
value = coerce_value(raw_value, option.type)
|
||||||
|
except Exception as error:
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"invalid value for '{argv[index]}': {error}"
|
||||||
|
) from error
|
||||||
|
|
||||||
|
if option.choices and value not in option.choices:
|
||||||
|
choices = ", ".join(str(choice) for choice in option.choices)
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"invalid value for '{argv[index]}': expected one of {{{choices}}}"
|
||||||
|
)
|
||||||
|
|
||||||
|
values[option.dest] = value
|
||||||
|
return index + 2
|
||||||
|
|
||||||
|
raise FalyxOptionError(f"unsupported option action: {option.action}")
|
||||||
|
|
||||||
|
def parse_args(
|
||||||
|
self,
|
||||||
|
argv: list[str] | None = None,
|
||||||
|
) -> ParseResult:
|
||||||
|
raw_argv = argv or []
|
||||||
|
arguments = self._resolve_posix_bundling(raw_argv)
|
||||||
|
values, root_values = self._default_values()
|
||||||
|
|
||||||
|
index = 0
|
||||||
|
while index < len(arguments):
|
||||||
|
token = arguments[index]
|
||||||
|
|
||||||
|
# Explicit option terminator. Everything after belongs to routing/command.
|
||||||
|
if token == "--":
|
||||||
|
index += 1
|
||||||
|
break
|
||||||
|
|
||||||
|
# First non-option is the route boundary.
|
||||||
|
if not token.startswith("-"):
|
||||||
|
break
|
||||||
|
|
||||||
|
# Unknown leading option is an error at this scope.
|
||||||
|
# This is what keeps root/namespace options honest.
|
||||||
|
option = self._options_by_dest.get(token)
|
||||||
|
if option is None:
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"unknown option '{token}' for '{self._flx.program or self._flx.title}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
target_values = root_values if option.scope == OptionScope.ROOT else values
|
||||||
|
index = self._consume_option(option, arguments, index, target_values)
|
||||||
|
|
||||||
|
remaining_argv = arguments[index:]
|
||||||
|
|
||||||
|
help_requested = values.get("help", False) or values.get("tldr", False)
|
||||||
|
|
||||||
|
return ParseResult(
|
||||||
|
mode=FalyxMode.HELP if help_requested else FalyxMode.COMMAND,
|
||||||
|
raw_argv=raw_argv,
|
||||||
|
options=values,
|
||||||
|
root_options=root_values,
|
||||||
|
remaining_argv=remaining_argv,
|
||||||
|
help=values.get("help", False),
|
||||||
|
tldr=values.get("tldr", False),
|
||||||
|
current_head=remaining_argv[0] if remaining_argv else "",
|
||||||
|
)
|
||||||
76
falyx/parser/group.py
Normal file
76
falyx/parser/group.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
|
"""Argument grouping models for the Falyx command argument parser.
|
||||||
|
|
||||||
|
This module defines lightweight dataclasses used by
|
||||||
|
`CommandArgumentParser` to organize arguments into named help sections and
|
||||||
|
mutually exclusive sets.
|
||||||
|
|
||||||
|
It provides:
|
||||||
|
|
||||||
|
- `ArgumentGroup`, which represents a logical collection of related argument
|
||||||
|
destinations for grouped help rendering.
|
||||||
|
- `MutuallyExclusiveGroup`, which represents a set of argument destinations
|
||||||
|
where only one member may be selected, with optional group-level
|
||||||
|
requiredness.
|
||||||
|
|
||||||
|
These models are metadata containers only. They do not perform parsing or
|
||||||
|
validation themselves. Instead, they are populated and enforced by
|
||||||
|
`CommandArgumentParser` during argument registration, parsing, and help
|
||||||
|
generation.
|
||||||
|
|
||||||
|
This module exists to keep argument-group state explicit, structured, and easy
|
||||||
|
to introspect.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ArgumentGroup:
|
||||||
|
"""Represents a named group of related command argument destinations.
|
||||||
|
|
||||||
|
`ArgumentGroup` is used by `CommandArgumentParser` to collect arguments that
|
||||||
|
belong together conceptually so they can be rendered under a shared section
|
||||||
|
in help output and tracked as a unit in parser metadata.
|
||||||
|
|
||||||
|
This class stores only grouping metadata and does not implement any parsing
|
||||||
|
behavior on its own.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: User-facing name of the argument group.
|
||||||
|
description: Optional descriptive text for the group, typically used in
|
||||||
|
help rendering.
|
||||||
|
dests: Destination names of arguments assigned to this group.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
dests: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class MutuallyExclusiveGroup:
|
||||||
|
"""Represents a mutually exclusive set of argument destinations.
|
||||||
|
|
||||||
|
`MutuallyExclusiveGroup` is used by `CommandArgumentParser` to model groups
|
||||||
|
of arguments where only one member may be provided at a time. It can also
|
||||||
|
mark the group as required, meaning that exactly one of the grouped
|
||||||
|
arguments must be present.
|
||||||
|
|
||||||
|
This class stores group metadata only. Validation and enforcement are
|
||||||
|
performed by the parser.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: User-facing name of the mutually exclusive group.
|
||||||
|
required: Whether at least one argument in the group must be supplied.
|
||||||
|
description: Optional descriptive text for the group, typically used in
|
||||||
|
help rendering.
|
||||||
|
dests: Destination names of arguments assigned to this mutually
|
||||||
|
exclusive group.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
required: bool = False
|
||||||
|
description: str = ""
|
||||||
|
dests: list[str] = field(default_factory=list)
|
||||||
64
falyx/parser/parse_result.py
Normal file
64
falyx/parser/parse_result.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
|
"""Parse result model for the Falyx CLI runtime.
|
||||||
|
|
||||||
|
This module defines `ParseResult`, the normalized output produced by the
|
||||||
|
root-level Falyx parsing stage.
|
||||||
|
|
||||||
|
`ParseResult` captures the session-scoped state derived from the initial
|
||||||
|
CLI parse before namespace routing or command-local argument parsing begins. It
|
||||||
|
records the selected top-level mode, the original argv, root option flags, and
|
||||||
|
any remaining argv that should be forwarded into the routed execution layer.
|
||||||
|
|
||||||
|
This model is typically produced by `FalyxParser.parse()` and then consumed by
|
||||||
|
higher-level Falyx runtime entrypoints such as `Falyx.run()` to configure
|
||||||
|
logging, prompt behavior, help rendering, and routed command dispatch.
|
||||||
|
|
||||||
|
The dataclass is intentionally lightweight and focused on root parsing only. It
|
||||||
|
does not perform parsing, validation, or execution itself.
|
||||||
|
"""
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from falyx.mode import FalyxMode
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ParseResult:
|
||||||
|
"""Represents the normalized result of root-level Falyx argument parsing.
|
||||||
|
|
||||||
|
`ParseResult` stores the outcome of the initial CLI parse that occurs at
|
||||||
|
the application boundary. It separates session-level runtime settings from
|
||||||
|
the remaining argv that should continue into namespace routing and
|
||||||
|
command-local parsing.
|
||||||
|
|
||||||
|
This model is used to communicate root parsing decisions cleanly to the
|
||||||
|
rest of the Falyx runtime, including whether the application should enter
|
||||||
|
help mode or continue with normal command execution.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
mode: Top-level runtime mode selected from the root parse.
|
||||||
|
raw_argv: Original argv passed into the root parser.
|
||||||
|
options: Dictionary of parsed root-level options and their values.
|
||||||
|
root_options: Dictionary of parsed root-level options that should be
|
||||||
|
applied at the root level for all namespaces.
|
||||||
|
remaining_argv: Unconsumed argv that should be forwarded to routed
|
||||||
|
command resolution.
|
||||||
|
current_head: The current head token being processed (for error reporting).
|
||||||
|
help: Whether help output was requested at the root level.
|
||||||
|
tldr: Whether TLDR output was requested at the root level.
|
||||||
|
verbose: Whether verbose logging should be enabled for the session.
|
||||||
|
debug_hooks: Whether hook execution should be logged in detail.
|
||||||
|
never_prompt: Whether prompts should be suppressed for the session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
mode: FalyxMode
|
||||||
|
raw_argv: list[str] = field(default_factory=list)
|
||||||
|
options: dict[str, Any] = field(default_factory=dict)
|
||||||
|
root_options: dict[str, Any] = field(default_factory=dict)
|
||||||
|
remaining_argv: list[str] = field(default_factory=list)
|
||||||
|
current_head: str = ""
|
||||||
|
help: bool = False
|
||||||
|
tldr: bool = False
|
||||||
|
verbose: bool = False
|
||||||
|
debug_hooks: bool = False
|
||||||
|
never_prompt: bool = False
|
||||||
78
falyx/parser/parser_types.py
Normal file
78
falyx/parser/parser_types.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 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, TypeAlias
|
||||||
|
|
||||||
|
from falyx.parser.argument import Argument
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ArgumentState:
|
||||||
|
"""Tracks an argument and whether it has been consumed."""
|
||||||
|
|
||||||
|
arg: Argument
|
||||||
|
consumed: bool = False
|
||||||
|
consumed_position: int | None = None
|
||||||
|
has_invalid_choice: bool = False
|
||||||
|
|
||||||
|
def set_consumed(self, position: int | None = None) -> None:
|
||||||
|
"""Mark this argument as consumed, optionally setting the position."""
|
||||||
|
self.consumed = True
|
||||||
|
self.consumed_position = position
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset the consumed state."""
|
||||||
|
self.consumed = False
|
||||||
|
self.consumed_position = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TLDRExample:
|
||||||
|
"""Represents a usage example for TLDR output."""
|
||||||
|
|
||||||
|
usage: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
TLDRInput: TypeAlias = TLDRExample | tuple[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FalyxTLDRExample:
|
||||||
|
"""Represents a usage example for Falyx TLDR output, with optional metadata."""
|
||||||
|
|
||||||
|
entry_key: str
|
||||||
|
usage: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
FalyxTLDRInput: TypeAlias = FalyxTLDRExample | tuple[str, str, 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
|
||||||
115
falyx/parser/signature.py
Normal file
115
falyx/parser/signature.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# 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()`.
|
||||||
|
|
||||||
|
It supports:
|
||||||
|
- Positional and keyword arguments
|
||||||
|
- Type hints for argument types
|
||||||
|
- Default values
|
||||||
|
- Required vs optional arguments
|
||||||
|
- Custom help text, choices, and suggestions via metadata
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- Only parameters with kind `POSITIONAL_ONLY`, `POSITIONAL_OR_KEYWORD`, or
|
||||||
|
`KEYWORD_ONLY` are considered.
|
||||||
|
- Parameters with kind `VAR_POSITIONAL` or `VAR_KEYWORD` are ignored.
|
||||||
|
|
||||||
|
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
|
||||||
169
falyx/parser/utils.py
Normal file
169
falyx/parser/utils.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# 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 get_type_name(type_: Any) -> str:
|
||||||
|
if hasattr(type_, "__name__"):
|
||||||
|
return type_.__name__
|
||||||
|
elif not isinstance(type_, type):
|
||||||
|
parent_type = type(type_)
|
||||||
|
if hasattr(parent_type, "__name__"):
|
||||||
|
return parent_type.__name__
|
||||||
|
return str(type_)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
@@ -1,17 +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
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"Argument",
|
|
||||||
"ArgumentAction",
|
|
||||||
"CommandArgumentParser",
|
|
||||||
"get_arg_parsers",
|
|
||||||
"FalyxParsers",
|
|
||||||
]
|
|
||||||
@@ -1,771 +0,0 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
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"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def choices(cls) -> list[ArgumentAction]:
|
|
||||||
"""Return a list of all argument actions."""
|
|
||||||
return list(cls)
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
"""Return the string representation of the argument action."""
|
|
||||||
return self.value
|
|
||||||
|
|
||||||
|
|
||||||
@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 self.action in (
|
|
||||||
ArgumentAction.STORE,
|
|
||||||
ArgumentAction.APPEND,
|
|
||||||
ArgumentAction.EXTEND,
|
|
||||||
) 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
|
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
|
||||||
@@ -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_parser.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,
|
|
||||||
)
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import inspect
|
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
from falyx.logger import logger
|
|
||||||
|
|
||||||
|
|
||||||
def infer_args_from_func(
|
|
||||||
func: Callable[[Any], Any] | None,
|
|
||||||
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Infer argument definitions from a callable's signature.
|
|
||||||
Returns a list of kwargs suitable for CommandArgumentParser.add_argument.
|
|
||||||
"""
|
|
||||||
if not callable(func):
|
|
||||||
logger.debug("Provided argument is not callable: %s", func)
|
|
||||||
return []
|
|
||||||
arg_metadata = arg_metadata or {}
|
|
||||||
signature = inspect.signature(func)
|
|
||||||
arg_defs = []
|
|
||||||
|
|
||||||
for name, param in signature.parameters.items():
|
|
||||||
raw_metadata = arg_metadata.get(name, {})
|
|
||||||
metadata = (
|
|
||||||
{"help": raw_metadata} if isinstance(raw_metadata, str) else raw_metadata
|
|
||||||
)
|
|
||||||
|
|
||||||
if param.kind not in (
|
|
||||||
inspect.Parameter.POSITIONAL_ONLY,
|
|
||||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
||||||
inspect.Parameter.KEYWORD_ONLY,
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
from typing import Any
|
|
||||||
|
|
||||||
from falyx import logger
|
|
||||||
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:
|
|
||||||
from falyx.action.action import BaseAction
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,30 +1,79 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 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 contextlib import contextmanager
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
from prompt_toolkit import PromptSession
|
from prompt_toolkit import PromptSession
|
||||||
from prompt_toolkit.formatted_text import (
|
from prompt_toolkit.formatted_text import (
|
||||||
AnyFormattedText,
|
AnyFormattedText,
|
||||||
FormattedText,
|
FormattedText,
|
||||||
|
StyleAndTextTuples,
|
||||||
merge_formatted_text,
|
merge_formatted_text,
|
||||||
)
|
)
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
from falyx.options_manager import OptionsManager
|
from falyx.options_manager import OptionsManager
|
||||||
from falyx.themes import OneColors
|
from falyx.themes import OneColors
|
||||||
from falyx.validators import yes_no_validator
|
from falyx.validators import yes_no_validator
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def prompt_session_context(session: PromptSession) -> Iterator[PromptSession]:
|
||||||
|
"""Temporary override for prompt session management"""
|
||||||
|
message = session.message
|
||||||
|
validator = session.validator
|
||||||
|
placeholder = session.placeholder
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
session.message = message
|
||||||
|
session.validator = validator
|
||||||
|
session.placeholder = placeholder
|
||||||
|
|
||||||
|
|
||||||
def should_prompt_user(
|
def should_prompt_user(
|
||||||
*,
|
*,
|
||||||
confirm: bool,
|
confirm: bool,
|
||||||
options: OptionsManager,
|
options: OptionsManager,
|
||||||
namespace: str = "cli_args",
|
namespace: str = "root",
|
||||||
):
|
override_namespace: str = "execution",
|
||||||
|
) -> bool:
|
||||||
|
"""Determine whether to prompt the user for confirmation.
|
||||||
|
|
||||||
|
Checks the `confirm` flag and consults the `OptionsManager` for any relevant
|
||||||
|
flags that may override the need for confirmation, such as `--never-prompt`,
|
||||||
|
`--force-confirm`, or `--skip-confirm`. The `override_namespace` is checked
|
||||||
|
first for any explicit overrides, followed by the main `namespace` for defaults.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
confirm (bool): The initial confirmation flag (e.g., from a command argument).
|
||||||
|
options (OptionsManager): The options manager to check for override flags.
|
||||||
|
namespace (str): The primary namespace to check for options (default: "root").
|
||||||
|
override_namespace (str): The secondary namespace for overrides (default: "execution").
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the user should be prompted, False if confirmation can be bypassed.
|
||||||
"""
|
"""
|
||||||
Determine whether to prompt the user for confirmation based on command
|
never_prompt = options.get("never_prompt", None, override_namespace)
|
||||||
and global options.
|
if never_prompt is None:
|
||||||
"""
|
never_prompt = options.get("never_prompt", False, namespace)
|
||||||
never_prompt = options.get("never_prompt", False, namespace)
|
|
||||||
force_confirm = options.get("force_confirm", False, namespace)
|
force_confirm = options.get("force_confirm", None, override_namespace)
|
||||||
skip_confirm = options.get("skip_confirm", False, namespace)
|
if force_confirm is None:
|
||||||
|
force_confirm = options.get("force_confirm", False, namespace)
|
||||||
|
|
||||||
|
skip_confirm = options.get("skip_confirm", None, override_namespace)
|
||||||
|
if skip_confirm is None:
|
||||||
|
skip_confirm = options.get("skip_confirm", False, namespace)
|
||||||
|
|
||||||
if never_prompt or skip_confirm:
|
if never_prompt or skip_confirm:
|
||||||
return False
|
return False
|
||||||
@@ -46,3 +95,38 @@ async def confirm_async(
|
|||||||
validator=yes_no_validator(),
|
validator=yes_no_validator(),
|
||||||
)
|
)
|
||||||
return answer.upper() == "Y"
|
return answer.upper() == "Y"
|
||||||
|
|
||||||
|
|
||||||
|
def rich_text_to_prompt_text(text: Text | str | StyleAndTextTuples) -> StyleAndTextTuples:
|
||||||
|
"""Convert a Rich Text object to prompt_toolkit formatted text.
|
||||||
|
|
||||||
|
This function takes a Rich `Text` object (or a string or already formatted text)
|
||||||
|
and converts it in to a list of (style, text) tuples compatible with prompt_toolkit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text (Text | str | StyleAndTextTuples): The input text to convert.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
StyleAndTextTuples: A list of (style, text) tuples for 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
|
||||||
|
|||||||
@@ -1,17 +1,33 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 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 __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
|
@runtime_checkable
|
||||||
class ActionFactoryProtocol(Protocol):
|
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
|
@runtime_checkable
|
||||||
class ArgParserProtocol(Protocol):
|
class ArgParserProtocol(Protocol):
|
||||||
def __call__(self, args: list[str]) -> tuple[tuple, dict]: ...
|
def __call__(
|
||||||
|
self, args: list[str]
|
||||||
|
) -> tuple[tuple, dict[str, Any], dict[str, Any]]: ...
|
||||||
|
|||||||
107
falyx/retry.py
107
falyx/retry.py
@@ -1,5 +1,22 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -12,7 +29,27 @@ from falyx.logger import logger
|
|||||||
|
|
||||||
|
|
||||||
class RetryPolicy(BaseModel):
|
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)
|
max_retries: int = Field(default=3, ge=0)
|
||||||
delay: float = Field(default=1.0, ge=0.0)
|
delay: float = Field(default=1.0, ge=0.0)
|
||||||
@@ -21,22 +58,35 @@ class RetryPolicy(BaseModel):
|
|||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
|
|
||||||
def enable_policy(self) -> None:
|
def enable_policy(self) -> None:
|
||||||
"""
|
"""Enable the retry policy."""
|
||||||
Enable the retry policy.
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
self.enabled = True
|
self.enabled = True
|
||||||
|
|
||||||
def is_active(self) -> bool:
|
def is_active(self) -> bool:
|
||||||
"""
|
"""Check if the retry policy is active."""
|
||||||
Check if the retry policy is active.
|
|
||||||
:return: True if the retry policy is active, False otherwise.
|
|
||||||
"""
|
|
||||||
return self.max_retries > 0 and self.enabled
|
return self.max_retries > 0 and self.enabled
|
||||||
|
|
||||||
|
|
||||||
class RetryHandler:
|
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()):
|
def __init__(self, policy: RetryPolicy = RetryPolicy()):
|
||||||
self.policy = policy
|
self.policy = policy
|
||||||
@@ -53,7 +103,7 @@ class RetryHandler:
|
|||||||
self.policy.delay = delay
|
self.policy.delay = delay
|
||||||
self.policy.backoff = backoff
|
self.policy.backoff = backoff
|
||||||
self.policy.jitter = jitter
|
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:
|
async def retry_on_error(self, context: ExecutionContext) -> None:
|
||||||
from falyx.action import Action
|
from falyx.action import Action
|
||||||
@@ -67,21 +117,21 @@ class RetryHandler:
|
|||||||
last_error = error
|
last_error = error
|
||||||
|
|
||||||
if not target:
|
if not target:
|
||||||
logger.warning("[%s] ⚠️ No action target. Cannot retry.", name)
|
logger.warning("[%s] No action target. Cannot retry.", name)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not isinstance(target, Action):
|
if not isinstance(target, Action):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"[%s] ❌ RetryHandler only supports only supports Action objects.", name
|
"[%s] RetryHandler only supports only supports Action objects.", name
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not getattr(target, "is_retryable", False):
|
if not getattr(target, "is_retryable", False):
|
||||||
logger.warning("[%s] ❌ Not retryable.", name)
|
logger.warning("[%s] Not retryable.", name)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not self.policy.enabled:
|
if not self.policy.enabled:
|
||||||
logger.warning("[%s] ❌ Retry policy is disabled.", name)
|
logger.warning("[%s] Retry policy is disabled.", name)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
while retries_done < self.policy.max_retries:
|
while retries_done < self.policy.max_retries:
|
||||||
@@ -90,32 +140,41 @@ class RetryHandler:
|
|||||||
sleep_delay = current_delay
|
sleep_delay = current_delay
|
||||||
if self.policy.jitter > 0:
|
if self.policy.jitter > 0:
|
||||||
sleep_delay += random.uniform(-self.policy.jitter, self.policy.jitter)
|
sleep_delay += random.uniform(-self.policy.jitter, self.policy.jitter)
|
||||||
|
logger.debug(
|
||||||
|
"[%s] Error: %s",
|
||||||
|
name,
|
||||||
|
last_error,
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"[%s] 🔄 Retrying (%s/%s) in %ss due to '%s'...",
|
"[%s] Retrying (%s/%s) in %ss due to '%s'...",
|
||||||
name,
|
name,
|
||||||
retries_done,
|
retries_done,
|
||||||
self.policy.max_retries,
|
self.policy.max_retries,
|
||||||
current_delay,
|
current_delay,
|
||||||
last_error,
|
last_error.__class__.__name__,
|
||||||
)
|
)
|
||||||
await asyncio.sleep(current_delay)
|
await asyncio.sleep(current_delay)
|
||||||
try:
|
try:
|
||||||
result = await target.action(*context.args, **context.kwargs)
|
result = await target.action(*context.args, **context.kwargs)
|
||||||
context.result = result
|
context.result = result
|
||||||
context.exception = None
|
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
|
return None
|
||||||
except Exception as retry_error:
|
except Exception as retry_error:
|
||||||
last_error = retry_error
|
last_error = retry_error
|
||||||
current_delay *= self.policy.backoff
|
current_delay *= self.policy.backoff
|
||||||
|
logger.debug(
|
||||||
|
"[%s] Error: %s",
|
||||||
|
name,
|
||||||
|
retry_error,
|
||||||
|
)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"[%s] ⚠️ Retry attempt %s/%s failed due to '%s'.",
|
"[%s] Retry attempt %s/%s failed due to '%s'.",
|
||||||
name,
|
name,
|
||||||
retries_done,
|
retries_done,
|
||||||
self.policy.max_retries,
|
self.policy.max_retries,
|
||||||
retry_error,
|
retry_error.__class__.__name__,
|
||||||
)
|
)
|
||||||
|
|
||||||
context.exception = last_error
|
context.exception = last_error
|
||||||
logger.error("[%s] ❌ All %s retries failed.", name, self.policy.max_retries)
|
logger.error("[%s] All %s retries failed.", name, self.policy.max_retries)
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""retry_utils.py"""
|
"""Utilities for enabling retry behavior across Falyx actions.
|
||||||
from falyx.action.action import Action, BaseAction
|
|
||||||
|
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.hook_manager import HookType
|
||||||
from falyx.retry import RetryHandler, RetryPolicy
|
from falyx.retry import RetryHandler, RetryPolicy
|
||||||
|
|
||||||
|
|||||||
95
falyx/routing.py
Normal file
95
falyx/routing.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
|
"""Routing result models for the Falyx CLI framework.
|
||||||
|
|
||||||
|
This module defines the core types used to describe the outcome of namespace
|
||||||
|
routing in a `Falyx` application.
|
||||||
|
|
||||||
|
It provides:
|
||||||
|
|
||||||
|
- `RouteKind`, an enum describing the kind of routed target that was reached,
|
||||||
|
such as a leaf command, namespace help, namespace TLDR, namespace menu, or
|
||||||
|
an unknown entry.
|
||||||
|
- `RouteResult`, a structured value object that captures the resolved routing
|
||||||
|
state, including the active namespace, invocation context, optional leaf
|
||||||
|
command, remaining argv for command-local parsing, and any suggestions for
|
||||||
|
unresolved input.
|
||||||
|
|
||||||
|
These types sit at the boundary between routing and execution. They do not
|
||||||
|
perform routing themselves. Instead, they are produced by Falyx routing logic
|
||||||
|
and then consumed by help rendering, completion, validation, preview, and
|
||||||
|
command dispatch flows.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from falyx.context import InvocationContext
|
||||||
|
from falyx.namespace import FalyxNamespace
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from falyx.command import Command
|
||||||
|
from falyx.falyx import Falyx
|
||||||
|
|
||||||
|
|
||||||
|
class RouteKind(Enum):
|
||||||
|
"""Enumerates the possible outcomes of Falyx namespace routing.
|
||||||
|
|
||||||
|
`RouteKind` identifies what the routing layer resolved the current input
|
||||||
|
to, allowing downstream code to decide whether it should execute a command,
|
||||||
|
render namespace help, show TLDR output, display a namespace menu, or
|
||||||
|
surface an unknown-entry message.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
COMMAND: Routing reached a leaf command that may be parsed and executed.
|
||||||
|
NAMESPACE_MENU: Routing stopped at a namespace menu target.
|
||||||
|
NAMESPACE_HELP: Routing resolved to namespace help output.
|
||||||
|
NAMESPACE_TLDR: Routing resolved to namespace TLDR output.
|
||||||
|
UNKNOWN: Routing failed to resolve the requested entry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
COMMAND = "command"
|
||||||
|
NAMESPACE_MENU = "namespace_menu"
|
||||||
|
NAMESPACE_HELP = "namespace_help"
|
||||||
|
NAMESPACE_TLDR = "namespace_tldr"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class RouteResult:
|
||||||
|
"""Represents the resolved output of a Falyx routing operation.
|
||||||
|
|
||||||
|
`RouteResult` captures the full state needed after namespace resolution
|
||||||
|
completes and before command execution or help rendering begins. It records
|
||||||
|
what kind of target was reached, where routing ended, the invocation path
|
||||||
|
used to reach it, and any leaf-command metadata needed for downstream
|
||||||
|
parsing.
|
||||||
|
|
||||||
|
This model is used by Falyx execution, help, preview, completion, and
|
||||||
|
validation flows to make routing decisions explicit and easy to inspect.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
kind: The type of routed result that was resolved.
|
||||||
|
namespace: The `Falyx` namespace where routing ended.
|
||||||
|
context: Invocation context describing the routed path and current mode.
|
||||||
|
command: Resolved leaf command, if routing ended at a command.
|
||||||
|
namespace_entry: Resolved namespace entry, if the route corresponds to a
|
||||||
|
specific nested namespace.
|
||||||
|
leaf_argv: Remaining argv that should be delegated to the resolved
|
||||||
|
command's local parser.
|
||||||
|
current_head: The current head token that routing is evaluating, used for
|
||||||
|
generating suggestions.
|
||||||
|
suggestions: Suggested entry names for unresolved input.
|
||||||
|
is_preview: Whether the routed invocation is in preview mode.
|
||||||
|
"""
|
||||||
|
|
||||||
|
kind: RouteKind
|
||||||
|
namespace: "Falyx"
|
||||||
|
context: InvocationContext
|
||||||
|
command: "Command | None" = None
|
||||||
|
namespace_entry: FalyxNamespace | None = None
|
||||||
|
leaf_argv: list[str] = field(default_factory=list)
|
||||||
|
current_head: str = ""
|
||||||
|
suggestions: list[str] = field(default_factory=list)
|
||||||
|
is_preview: bool = False
|
||||||
@@ -1,17 +1,29 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 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 dataclasses import dataclass
|
||||||
from typing import Any, Callable, KeysView, Sequence
|
from typing import Any, Callable, KeysView, Sequence
|
||||||
|
|
||||||
from prompt_toolkit import PromptSession
|
from prompt_toolkit import PromptSession
|
||||||
from rich import box
|
from rich import box
|
||||||
from rich.console import Console
|
|
||||||
from rich.markup import escape
|
from rich.markup import escape
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
|
from falyx.console import console
|
||||||
|
from falyx.prompt_utils import prompt_session_context, rich_text_to_prompt_text
|
||||||
from falyx.themes import OneColors
|
from falyx.themes import OneColors
|
||||||
from falyx.utils import CaseInsensitiveDict, chunks
|
from falyx.utils import CaseInsensitiveDict, chunks
|
||||||
from falyx.validators import int_range_validator, key_validator
|
from falyx.validators import MultiIndexValidator, MultiKeyValidator
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -33,9 +45,7 @@ class SelectionOption:
|
|||||||
|
|
||||||
|
|
||||||
class SelectionOptionMap(CaseInsensitiveDict):
|
class SelectionOptionMap(CaseInsensitiveDict):
|
||||||
"""
|
"""Manages selection options including validation and reserved key protection."""
|
||||||
Manages selection options including validation and reserved key protection.
|
|
||||||
"""
|
|
||||||
|
|
||||||
RESERVED_KEYS: set[str] = set()
|
RESERVED_KEYS: set[str] = set()
|
||||||
|
|
||||||
@@ -105,6 +115,7 @@ def render_table_base(
|
|||||||
highlight: bool = True,
|
highlight: bool = True,
|
||||||
column_names: Sequence[str] | None = None,
|
column_names: Sequence[str] | None = None,
|
||||||
) -> Table:
|
) -> Table:
|
||||||
|
"""Render the base table for selection prompts."""
|
||||||
table = Table(
|
table = Table(
|
||||||
title=title,
|
title=title,
|
||||||
caption=caption,
|
caption=caption,
|
||||||
@@ -267,23 +278,51 @@ async def prompt_for_index(
|
|||||||
*,
|
*,
|
||||||
min_index: int = 0,
|
min_index: int = 0,
|
||||||
default_selection: str = "",
|
default_selection: str = "",
|
||||||
console: Console | None = None,
|
|
||||||
prompt_session: PromptSession | None = None,
|
prompt_session: PromptSession | None = None,
|
||||||
prompt_message: str = "Select an option > ",
|
prompt_message: str = "Select an option > ",
|
||||||
show_table: bool = True,
|
show_table: bool = True,
|
||||||
):
|
number_selections: int | str = 1,
|
||||||
|
separator: str = ",",
|
||||||
|
allow_duplicates: bool = False,
|
||||||
|
cancel_key: str = "",
|
||||||
|
) -> int | list[int]:
|
||||||
|
"""Prompt the user to select an index from a table of options. Return the selected index."""
|
||||||
prompt_session = prompt_session or PromptSession()
|
prompt_session = prompt_session or PromptSession()
|
||||||
console = console or Console(color_system="auto")
|
|
||||||
|
|
||||||
if show_table:
|
if show_table:
|
||||||
console.print(table, justify="center")
|
console.print(table, justify="center")
|
||||||
|
|
||||||
selection = await prompt_session.prompt_async(
|
number_selections_str = (
|
||||||
message=prompt_message,
|
f"{number_selections} " if isinstance(number_selections, int) else ""
|
||||||
validator=int_range_validator(min_index, max_index),
|
|
||||||
default=default_selection,
|
|
||||||
)
|
)
|
||||||
return int(selection)
|
|
||||||
|
plural = "s" if number_selections != 1 else ""
|
||||||
|
placeholder = (
|
||||||
|
f"Enter {number_selections_str}selection{plural} separated by '{separator}'"
|
||||||
|
if number_selections != 1
|
||||||
|
else "Enter selection"
|
||||||
|
)
|
||||||
|
|
||||||
|
with prompt_session_context(prompt_session) as session:
|
||||||
|
selection = await session.prompt_async(
|
||||||
|
message=rich_text_to_prompt_text(prompt_message),
|
||||||
|
validator=MultiIndexValidator(
|
||||||
|
min_index,
|
||||||
|
max_index,
|
||||||
|
number_selections,
|
||||||
|
separator,
|
||||||
|
allow_duplicates,
|
||||||
|
cancel_key,
|
||||||
|
),
|
||||||
|
default=default_selection,
|
||||||
|
placeholder=placeholder,
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
async def prompt_for_selection(
|
||||||
@@ -291,35 +330,59 @@ async def prompt_for_selection(
|
|||||||
table: Table,
|
table: Table,
|
||||||
*,
|
*,
|
||||||
default_selection: str = "",
|
default_selection: str = "",
|
||||||
console: Console | None = None,
|
|
||||||
prompt_session: PromptSession | None = None,
|
prompt_session: PromptSession | None = None,
|
||||||
prompt_message: str = "Select an option > ",
|
prompt_message: str = "Select an option > ",
|
||||||
show_table: bool = True,
|
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 the user to select a key from a set of options. Return the selected key."""
|
||||||
prompt_session = prompt_session or PromptSession()
|
prompt_session = prompt_session or PromptSession()
|
||||||
console = console or Console(color_system="auto")
|
|
||||||
|
|
||||||
if show_table:
|
if show_table:
|
||||||
console.print(table, justify="center")
|
console.print(table, justify="center")
|
||||||
|
|
||||||
selected = await prompt_session.prompt_async(
|
number_selections_str = (
|
||||||
message=prompt_message,
|
f"{number_selections} " if isinstance(number_selections, int) else ""
|
||||||
validator=key_validator(keys),
|
|
||||||
default=default_selection,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return selected
|
plural = "s" if number_selections != 1 else ""
|
||||||
|
placeholder = (
|
||||||
|
f"Enter {number_selections_str}selection{plural} separated by '{separator}'"
|
||||||
|
if number_selections != 1
|
||||||
|
else "Enter selection"
|
||||||
|
)
|
||||||
|
|
||||||
|
with prompt_session_context(prompt_session) as session:
|
||||||
|
selected = await session.prompt_async(
|
||||||
|
message=rich_text_to_prompt_text(prompt_message),
|
||||||
|
validator=MultiKeyValidator(
|
||||||
|
keys, number_selections, separator, allow_duplicates, cancel_key
|
||||||
|
),
|
||||||
|
default=default_selection,
|
||||||
|
placeholder=placeholder,
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
async def select_value_from_list(
|
||||||
title: str,
|
title: str,
|
||||||
selections: Sequence[str],
|
selections: Sequence[str],
|
||||||
*,
|
*,
|
||||||
console: Console | None = None,
|
|
||||||
prompt_session: PromptSession | None = None,
|
prompt_session: PromptSession | None = None,
|
||||||
prompt_message: str = "Select an option > ",
|
prompt_message: str = "Select an option > ",
|
||||||
default_selection: str = "",
|
default_selection: str = "",
|
||||||
|
number_selections: int | str = 1,
|
||||||
|
separator: str = ",",
|
||||||
|
allow_duplicates: bool = False,
|
||||||
|
cancel_key: str = "",
|
||||||
columns: int = 4,
|
columns: int = 4,
|
||||||
caption: str = "",
|
caption: str = "",
|
||||||
box_style: box.Box = box.SIMPLE,
|
box_style: box.Box = box.SIMPLE,
|
||||||
@@ -332,7 +395,7 @@ async def select_value_from_list(
|
|||||||
title_style: str = "",
|
title_style: str = "",
|
||||||
caption_style: str = "",
|
caption_style: str = "",
|
||||||
highlight: bool = False,
|
highlight: bool = False,
|
||||||
):
|
) -> str | list[str]:
|
||||||
"""Prompt for a selection. Return the selected item."""
|
"""Prompt for a selection. Return the selected item."""
|
||||||
table = render_selection_indexed_table(
|
table = render_selection_indexed_table(
|
||||||
title=title,
|
title=title,
|
||||||
@@ -351,17 +414,21 @@ async def select_value_from_list(
|
|||||||
highlight=highlight,
|
highlight=highlight,
|
||||||
)
|
)
|
||||||
prompt_session = prompt_session or PromptSession()
|
prompt_session = prompt_session or PromptSession()
|
||||||
console = console or Console(color_system="auto")
|
|
||||||
|
|
||||||
selection_index = await prompt_for_index(
|
selection_index = await prompt_for_index(
|
||||||
len(selections) - 1,
|
len(selections) - 1,
|
||||||
table,
|
table,
|
||||||
default_selection=default_selection,
|
default_selection=default_selection,
|
||||||
console=console,
|
|
||||||
prompt_session=prompt_session,
|
prompt_session=prompt_session,
|
||||||
prompt_message=prompt_message,
|
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]
|
return selections[selection_index]
|
||||||
|
|
||||||
|
|
||||||
@@ -369,14 +436,16 @@ async def select_key_from_dict(
|
|||||||
selections: dict[str, SelectionOption],
|
selections: dict[str, SelectionOption],
|
||||||
table: Table,
|
table: Table,
|
||||||
*,
|
*,
|
||||||
console: Console | None = None,
|
|
||||||
prompt_session: PromptSession | None = None,
|
prompt_session: PromptSession | None = None,
|
||||||
prompt_message: str = "Select an option > ",
|
prompt_message: str = "Select an option > ",
|
||||||
default_selection: str = "",
|
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 for a key from a dict, returns the key."""
|
||||||
prompt_session = prompt_session or PromptSession()
|
prompt_session = prompt_session or PromptSession()
|
||||||
console = console or Console(color_system="auto")
|
|
||||||
|
|
||||||
console.print(table, justify="center")
|
console.print(table, justify="center")
|
||||||
|
|
||||||
@@ -384,9 +453,12 @@ async def select_key_from_dict(
|
|||||||
selections.keys(),
|
selections.keys(),
|
||||||
table,
|
table,
|
||||||
default_selection=default_selection,
|
default_selection=default_selection,
|
||||||
console=console,
|
|
||||||
prompt_session=prompt_session,
|
prompt_session=prompt_session,
|
||||||
prompt_message=prompt_message,
|
prompt_message=prompt_message,
|
||||||
|
number_selections=number_selections,
|
||||||
|
separator=separator,
|
||||||
|
allow_duplicates=allow_duplicates,
|
||||||
|
cancel_key=cancel_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -394,14 +466,16 @@ async def select_value_from_dict(
|
|||||||
selections: dict[str, SelectionOption],
|
selections: dict[str, SelectionOption],
|
||||||
table: Table,
|
table: Table,
|
||||||
*,
|
*,
|
||||||
console: Console | None = None,
|
|
||||||
prompt_session: PromptSession | None = None,
|
prompt_session: PromptSession | None = None,
|
||||||
prompt_message: str = "Select an option > ",
|
prompt_message: str = "Select an option > ",
|
||||||
default_selection: str = "",
|
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 for a key from a dict, but return the value."""
|
||||||
prompt_session = prompt_session or PromptSession()
|
prompt_session = prompt_session or PromptSession()
|
||||||
console = console or Console(color_system="auto")
|
|
||||||
|
|
||||||
console.print(table, justify="center")
|
console.print(table, justify="center")
|
||||||
|
|
||||||
@@ -409,11 +483,16 @@ async def select_value_from_dict(
|
|||||||
selections.keys(),
|
selections.keys(),
|
||||||
table,
|
table,
|
||||||
default_selection=default_selection,
|
default_selection=default_selection,
|
||||||
console=console,
|
|
||||||
prompt_session=prompt_session,
|
prompt_session=prompt_session,
|
||||||
prompt_message=prompt_message,
|
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
|
return selections[selection_key].value
|
||||||
|
|
||||||
|
|
||||||
@@ -421,11 +500,14 @@ async def get_selection_from_dict_menu(
|
|||||||
title: str,
|
title: str,
|
||||||
selections: dict[str, SelectionOption],
|
selections: dict[str, SelectionOption],
|
||||||
*,
|
*,
|
||||||
console: Console | None = None,
|
|
||||||
prompt_session: PromptSession | None = None,
|
prompt_session: PromptSession | None = None,
|
||||||
prompt_message: str = "Select an option > ",
|
prompt_message: str = "Select an option > ",
|
||||||
default_selection: str = "",
|
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."""
|
"""Prompt for a key from a dict, but return the value."""
|
||||||
table = render_selection_dict_table(
|
table = render_selection_dict_table(
|
||||||
title,
|
title,
|
||||||
@@ -435,8 +517,11 @@ async def get_selection_from_dict_menu(
|
|||||||
return await select_value_from_dict(
|
return await select_value_from_dict(
|
||||||
selections=selections,
|
selections=selections,
|
||||||
table=table,
|
table=table,
|
||||||
console=console,
|
|
||||||
prompt_session=prompt_session,
|
prompt_session=prompt_session,
|
||||||
prompt_message=prompt_message,
|
prompt_message=prompt_message,
|
||||||
default_selection=default_selection,
|
default_selection=default_selection,
|
||||||
|
number_selections=number_selections,
|
||||||
|
separator=separator,
|
||||||
|
allow_duplicates=allow_duplicates,
|
||||||
|
cancel_key=cancel_key,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 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):
|
class FlowSignal(BaseException):
|
||||||
@@ -10,6 +25,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):
|
class QuitSignal(FlowSignal):
|
||||||
"""Raised to signal an immediate exit from the CLI framework."""
|
"""Raised to signal an immediate exit from the CLI framework."""
|
||||||
|
|
||||||
|
|||||||
245
falyx/spinner_manager.py
Normal file
245
falyx/spinner_manager.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 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())
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 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 collections import defaultdict
|
||||||
|
|
||||||
from rich import box
|
from rich import box
|
||||||
@@ -15,19 +24,19 @@ def build_tagged_table(flx: Falyx) -> Table:
|
|||||||
|
|
||||||
# Group commands by first tag
|
# Group commands by first tag
|
||||||
grouped: dict[str, list[Command]] = defaultdict(list)
|
grouped: dict[str, list[Command]] = defaultdict(list)
|
||||||
for cmd in flx.commands.values():
|
for command in flx.commands.values():
|
||||||
first_tag = cmd.tags[0] if cmd.tags else "Other"
|
first_tag = command.tags[0] if command.tags else "Other"
|
||||||
grouped[first_tag.capitalize()].append(cmd)
|
grouped[first_tag.capitalize()].append(command)
|
||||||
|
|
||||||
# Add grouped commands to table
|
# Add grouped commands to table
|
||||||
for group_name, commands in grouped.items():
|
for group_name, commands in grouped.items():
|
||||||
table.add_row(f"[bold underline]{group_name} Commands[/]")
|
table.add_row(f"[bold underline]{group_name} Commands[/]")
|
||||||
for cmd in commands:
|
for command in commands:
|
||||||
table.add_row(f"[{cmd.key}] [{cmd.style}]{cmd.description}")
|
table.add_row(f"[{command.key}] [{command.style}]{command.description}")
|
||||||
table.add_row("")
|
table.add_row("")
|
||||||
|
|
||||||
# Add bottom row
|
# Add bottom row
|
||||||
for row in flx.get_bottom_row():
|
for row in flx._get_bottom_row():
|
||||||
table.add_row(row)
|
table.add_row(row)
|
||||||
|
|
||||||
return table
|
return table
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""
|
"""Falyx CLI Framework
|
||||||
Falyx CLI Framework
|
|
||||||
|
|
||||||
Copyright (c) 2025 rtj.dev LLC.
|
Copyright (c) 2026 rtj.dev LLC.
|
||||||
Licensed under the MIT License. See LICENSE file for details.
|
Licensed under the MIT License. See LICENSE file for details.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"""
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
colors.py
|
"""A Python module that integrates the Nord color palette with the Rich library.
|
||||||
|
|
||||||
A Python module that integrates the Nord color palette with the Rich library.
|
|
||||||
It defines a metaclass-based NordColors class allowing dynamic attribute lookups
|
It defines a metaclass-based NordColors class allowing dynamic attribute lookups
|
||||||
(e.g., NORD12bu -> "#D08770 bold underline") and provides a comprehensive Nord-based
|
(e.g., NORD12bu -> "#D08770 bold underline") and provides a comprehensive Nord-based
|
||||||
Theme that customizes Rich's default styles.
|
Theme that customizes Rich's default styles.
|
||||||
@@ -27,8 +25,7 @@ from rich.theme import Theme
|
|||||||
|
|
||||||
|
|
||||||
class ColorsMeta(type):
|
class ColorsMeta(type):
|
||||||
"""
|
"""A metaclass that catches attribute lookups like `NORD12buidrs` or `ORANGE_b` and returns
|
||||||
A metaclass that catches attribute lookups like `NORD12buidrs` or `ORANGE_b` and returns
|
|
||||||
a string combining the base color + bold/italic/underline/dim/reverse/strike flags.
|
a string combining the base color + bold/italic/underline/dim/reverse/strike flags.
|
||||||
|
|
||||||
The color values are required to be uppercase with optional underscores and digits,
|
The color values are required to be uppercase with optional underscores and digits,
|
||||||
@@ -153,8 +150,7 @@ class OneColors(metaclass=ColorsMeta):
|
|||||||
|
|
||||||
|
|
||||||
class NordColors(metaclass=ColorsMeta):
|
class NordColors(metaclass=ColorsMeta):
|
||||||
"""
|
"""Defines the Nord color palette as class attributes.
|
||||||
Defines the Nord color palette as class attributes.
|
|
||||||
|
|
||||||
Each color is labeled by its canonical Nord name (NORD0-NORD15)
|
Each color is labeled by its canonical Nord name (NORD0-NORD15)
|
||||||
and also has useful aliases grouped by theme:
|
and also has useful aliases grouped by theme:
|
||||||
@@ -213,8 +209,7 @@ class NordColors(metaclass=ColorsMeta):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def as_dict(cls):
|
def as_dict(cls):
|
||||||
"""
|
"""Returns a dictionary mapping every NORD* attribute
|
||||||
Returns a dictionary mapping every NORD* attribute
|
|
||||||
(e.g. 'NORD0') to its hex code.
|
(e.g. 'NORD0') to its hex code.
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
@@ -225,8 +220,7 @@ class NordColors(metaclass=ColorsMeta):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def aliases(cls):
|
def aliases(cls):
|
||||||
"""
|
"""Returns a dictionary of *all* other aliases
|
||||||
Returns a dictionary of *all* other aliases
|
|
||||||
(Polar Night, Snow Storm, Frost, Aurora).
|
(Polar Night, Snow Storm, Frost, Aurora).
|
||||||
"""
|
"""
|
||||||
skip_prefixes = ("NORD", "__")
|
skip_prefixes = ("NORD", "__")
|
||||||
@@ -463,9 +457,7 @@ NORD_THEME_STYLES: dict[str, Style] = {
|
|||||||
|
|
||||||
|
|
||||||
def get_nord_theme() -> Theme:
|
def get_nord_theme() -> Theme:
|
||||||
"""
|
"""Returns a Rich Theme for the Nord color palette."""
|
||||||
Returns a Rich Theme for the Nord color palette.
|
|
||||||
"""
|
|
||||||
return Theme(NORD_THEME_STYLES)
|
return Theme(NORD_THEME_STYLES)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 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
|
from __future__ import annotations
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
@@ -14,6 +29,8 @@ from typing import Any, Awaitable, Callable, TypeVar
|
|||||||
import pythonjsonlogger.json
|
import pythonjsonlogger.json
|
||||||
from rich.logging import RichHandler
|
from rich.logging import RichHandler
|
||||||
|
|
||||||
|
from falyx.console import console
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
@@ -112,8 +129,7 @@ def setup_logging(
|
|||||||
file_log_level: int = logging.DEBUG,
|
file_log_level: int = logging.DEBUG,
|
||||||
console_log_level: int = logging.WARNING,
|
console_log_level: int = logging.WARNING,
|
||||||
):
|
):
|
||||||
"""
|
"""Configure logging for Falyx with support for both CLI-friendly and structured
|
||||||
Configure logging for Falyx with support for both CLI-friendly and structured
|
|
||||||
JSON output.
|
JSON output.
|
||||||
|
|
||||||
This function sets up separate logging handlers for console and file output,
|
This function sets up separate logging handlers for console and file output,
|
||||||
@@ -164,6 +180,7 @@ def setup_logging(
|
|||||||
|
|
||||||
if mode == "cli":
|
if mode == "cli":
|
||||||
console_handler: RichHandler | logging.StreamHandler = RichHandler(
|
console_handler: RichHandler | logging.StreamHandler = RichHandler(
|
||||||
|
console=console,
|
||||||
rich_tracebacks=True,
|
rich_tracebacks=True,
|
||||||
show_time=True,
|
show_time=True,
|
||||||
show_level=True,
|
show_level=True,
|
||||||
@@ -184,7 +201,7 @@ def setup_logging(
|
|||||||
console_handler.setLevel(console_log_level)
|
console_handler.setLevel(console_log_level)
|
||||||
root.addHandler(console_handler)
|
root.addHandler(console_handler)
|
||||||
|
|
||||||
file_handler = logging.FileHandler(log_filename)
|
file_handler = logging.FileHandler(log_filename, "a", "UTF-8")
|
||||||
file_handler.setLevel(file_log_level)
|
file_handler.setLevel(file_log_level)
|
||||||
if json_log_to_file:
|
if json_log_to_file:
|
||||||
file_handler.setFormatter(
|
file_handler.setFormatter(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user