Compare commits
18 Commits
dc1764e752
...
main
Author | SHA1 | Date | |
---|---|---|---|
3b2c33d28f
|
|||
f37aee568d
|
|||
8a0a45e17f
|
|||
da38f6d6ee
|
|||
7836ff4dfd
|
|||
7dca416346
|
|||
734f7b5962
|
|||
489d730755
|
|||
825ff60f08
|
|||
fa5e2a4c2c
|
|||
de53c889a6
|
|||
0319058531
|
|||
5769882afd
|
|||
7f63e16097
|
|||
21402bff9a
|
|||
fddc3ea8d9 | |||
9b9f6434a4
|
|||
c15e3afa5e
|
145
README.md
145
README.md
@ -10,7 +10,7 @@
|
||||
- ⚙️ Full lifecycle hooks (before, after, success, error, teardown)
|
||||
- 📊 Execution tracing, logging, and introspection
|
||||
- 🧙♂️ Async-first design with Process support
|
||||
- 🧩 Extensible CLI menus and customizable output
|
||||
- 🧩 Extensible CLI menus, customizable bottom bars, and keyboard shortcuts
|
||||
|
||||
> Built for developers who value *clarity*, *resilience*, and *visibility* in their terminal workflows.
|
||||
|
||||
@ -21,12 +21,13 @@
|
||||
Modern CLI tools deserve the same resilience as production systems. Falyx makes it easy to:
|
||||
|
||||
- Compose workflows using `Action`, `ChainedAction`, or `ActionGroup`
|
||||
- Inject the result of one step into the next (`last_result`)
|
||||
- Handle flaky operations with retries and exponential backoff
|
||||
- Inject the result of one step into the next (`last_result` / `auto_inject`)
|
||||
- Handle flaky operations with retries, backoff, and jitter
|
||||
- Roll back safely on failure with structured undo logic
|
||||
- Add observability with execution timing, result tracking, and hooks
|
||||
- Add observability with timing, tracebacks, and lifecycle hooks
|
||||
- Run in both interactive *and* headless (scriptable) modes
|
||||
- Customize output with Rich `Table`s (grouping, theming, etc.)
|
||||
- Support config-driven workflows with YAML or TOML
|
||||
- Visualize tagged command groups and menu state via Rich tables
|
||||
|
||||
---
|
||||
|
||||
@ -60,6 +61,7 @@ async def flaky_step():
|
||||
await asyncio.sleep(0.2)
|
||||
if random.random() < 0.5:
|
||||
raise RuntimeError("Random failure!")
|
||||
print("ok")
|
||||
return "ok"
|
||||
|
||||
# Create the actions
|
||||
@ -78,6 +80,8 @@ falyx.add_command(
|
||||
preview_before_confirm=True,
|
||||
confirm=True,
|
||||
retry_all=True,
|
||||
spinner=True,
|
||||
style="cyan",
|
||||
)
|
||||
|
||||
# Entry point
|
||||
@ -86,76 +90,131 @@ if __name__ == "__main__":
|
||||
```
|
||||
|
||||
```bash
|
||||
❯ python simple.py
|
||||
$ python simple.py
|
||||
🚀 Falyx Demo
|
||||
|
||||
[R] Run My Pipeline
|
||||
[Y] History [Q] Exit
|
||||
[H] Help [Y] History [X] Exit
|
||||
|
||||
>
|
||||
```
|
||||
|
||||
```bash
|
||||
❯ python simple.py run R
|
||||
$ python simple.py run r
|
||||
Command: 'R' — Run My Pipeline
|
||||
└── ⛓ ChainedAction 'my_pipeline'
|
||||
├── ⚙ Action 'step_1'
|
||||
│ ↻ Retries: 3x, delay 1.0s, backoff 2.0x
|
||||
└── ⚙ Action 'step_2'
|
||||
↻ Retries: 3x, delay 1.0s, backoff 2.0x
|
||||
Confirm execution of R — Run My Pipeline (calls `my_pipeline`) [Y/n] y
|
||||
[2025-04-15 22:03:57] WARNING ⚠️ Retry attempt 1/3 failed due to 'Random failure!'.
|
||||
✅ Result: ['ok', 'ok']
|
||||
❓ Confirm execution of R — Run My Pipeline (calls `my_pipeline`) [Y/n] > y
|
||||
[2025-07-20 09:29:35] WARNING Retry attempt 1/3 failed due to 'Random failure!'.
|
||||
ok
|
||||
[2025-07-20 09:29:38] WARNING Retry attempt 1/3 failed due to 'Random failure!'.
|
||||
ok
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Core Features
|
||||
|
||||
- ✅ Async-native `Action`, `ChainedAction`, `ActionGroup`
|
||||
- 🔁 Retry policies + exponential backoff
|
||||
- ⛓ Rollbacks on chained failures
|
||||
- 🎛️ Headless or interactive CLI with argparse and prompt_toolkit
|
||||
- 📊 Built-in execution registry, result tracking, and timing
|
||||
- 🧠 Supports `ProcessAction` for CPU-bound workloads
|
||||
- 🧩 Custom `Table` rendering for CLI menu views
|
||||
- 🔍 Hook lifecycle: `before`, `on_success`, `on_error`, `after`, `on_teardown`
|
||||
- ✅ Async-native `Action`, `ChainedAction`, `ActionGroup`, `ProcessAction`
|
||||
- 🔁 Retry policies with delay, backoff, jitter — opt-in per action or globally
|
||||
- ⛓ Rollbacks and lifecycle hooks for chained execution
|
||||
- 🎛️ Headless or interactive CLI powered by `argparse` + `prompt_toolkit`
|
||||
- 📊 In-memory `ExecutionRegistry` with result tracking, timing, and tracebacks
|
||||
- 🌐 CLI menu construction via config files or Python
|
||||
- ⚡ Bottom bar toggle switches and counters with `Ctrl+<key>` shortcuts
|
||||
- 🔍 Structured confirmation prompts and help rendering
|
||||
- 🪵 Flexible logging: Rich console for devs, JSON logs for ops
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Execution Trace
|
||||
### 🧰 Building Blocks
|
||||
|
||||
```bash
|
||||
[2025-04-14 10:33:22] DEBUG [Step 1] ⚙ flaky_step()
|
||||
[2025-04-14 10:33:22] INFO [Step 1] 🔁 Retrying (1/3) in 1.0s...
|
||||
[2025-04-14 10:33:23] DEBUG [Step 1] ✅ Success | Result: ok
|
||||
[2025-04-14 10:33:23] DEBUG [My Pipeline] ✅ Result: ['ok', 'ok']
|
||||
- **`Action`**: A single unit of async (or sync) logic
|
||||
- **`ChainedAction`**: Execute a sequence of actions, with rollback and injection
|
||||
- **`ActionGroup`**: Run actions concurrently and collect results
|
||||
- **`ProcessAction`**: Use `multiprocessing` for CPU-bound workflows
|
||||
- **`Falyx`**: Interactive or headless CLI controller with history, menus, and theming
|
||||
- **`ExecutionContext`**: Metadata store per invocation (name, args, result, timing)
|
||||
- **`HookManager`**: Attach `before`, `after`, `on_success`, `on_error`, `on_teardown`
|
||||
|
||||
---
|
||||
|
||||
### 🔍 Logging
|
||||
```
|
||||
2025-07-20 09:29:32 [falyx] [INFO] Command 'R' selected.
|
||||
2025-07-20 09:29:32 [falyx] [INFO] [run_key] Executing: R — Run My Pipeline
|
||||
2025-07-20 09:29:33 [falyx] [INFO] [my_pipeline] Starting -> ChainedAction(name=my_pipeline, actions=['step_1', 'step_2'], args=(), kwargs={}, auto_inject=False, return_list=False)()
|
||||
2025-07-20 09:29:33 [falyx] [INFO] [step_1] Retrying (1/3) in 1.0s due to 'Random failure!'...
|
||||
2025-07-20 09:29:35 [falyx] [WARNING] [step_1] Retry attempt 1/3 failed due to 'Random failure!'.
|
||||
2025-07-20 09:29:35 [falyx] [INFO] [step_1] Retrying (2/3) in 2.0s due to 'Random failure!'...
|
||||
2025-07-20 09:29:37 [falyx] [INFO] [step_1] Retry succeeded on attempt 2.
|
||||
2025-07-20 09:29:37 [falyx] [INFO] [step_1] Recovered: step_1
|
||||
2025-07-20 09:29:37 [falyx] [DEBUG] [step_1] status=OK duration=3.627s result='ok' exception=None
|
||||
2025-07-20 09:29:37 [falyx] [INFO] [step_2] Retrying (1/3) in 1.0s due to 'Random failure!'...
|
||||
2025-07-20 09:29:38 [falyx] [WARNING] [step_2] Retry attempt 1/3 failed due to 'Random failure!'.
|
||||
2025-07-20 09:29:38 [falyx] [INFO] [step_2] Retrying (2/3) in 2.0s due to 'Random failure!'...
|
||||
2025-07-20 09:29:40 [falyx] [INFO] [step_2] Retry succeeded on attempt 2.
|
||||
2025-07-20 09:29:40 [falyx] [INFO] [step_2] Recovered: step_2
|
||||
2025-07-20 09:29:40 [falyx] [DEBUG] [step_2] status=OK duration=3.609s result='ok' exception=None
|
||||
2025-07-20 09:29:40 [falyx] [DEBUG] [my_pipeline] Success -> Result: 'ok'
|
||||
2025-07-20 09:29:40 [falyx] [DEBUG] [my_pipeline] Finished in 7.237s
|
||||
2025-07-20 09:29:40 [falyx] [DEBUG] [my_pipeline] status=OK duration=7.237s result='ok' exception=None
|
||||
2025-07-20 09:29:40 [falyx] [DEBUG] [Run My Pipeline] status=OK duration=7.238s result='ok' exception=None
|
||||
```
|
||||
|
||||
---
|
||||
### 📊 History Tracking
|
||||
|
||||
### 🧱 Core Building Blocks
|
||||
View full execution history:
|
||||
|
||||
#### `Action`
|
||||
A single async unit of work. Painless retry support.
|
||||
```bash
|
||||
> history
|
||||
📊 Execution History
|
||||
|
||||
#### `ChainedAction`
|
||||
Run tasks in sequence. Supports rollback on failure and context propagation.
|
||||
Index Name Start End Duration Status Result / Exception
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
0 step_1 09:23:55 09:23:55 0.201s ✅ Success 'ok'
|
||||
1 step_2 09:23:55 09:24:03 7.829s ❌ Error RuntimeError('Random failure!')
|
||||
2 my_pipeline 09:23:55 09:24:03 8.080s ❌ Error RuntimeError('Random failure!')
|
||||
3 Run My Pipeline 09:23:55 09:24:03 8.082s ❌ Error RuntimeError('Random failure!')
|
||||
```
|
||||
|
||||
#### `ActionGroup`
|
||||
Run tasks in parallel. Useful for fan-out operations like batch API calls.
|
||||
Inspect result by index:
|
||||
|
||||
#### `ProcessAction`
|
||||
Offload CPU-bound work to another process — no extra code needed.
|
||||
```bash
|
||||
> history --result-index 0
|
||||
Action(name='step_1', action=flaky_step, args=(), kwargs={}, retry=True, rollback=False) ():
|
||||
ok
|
||||
```
|
||||
|
||||
#### `Falyx`
|
||||
Your CLI controller — powers menus, subcommands, history, bottom bars, and more.
|
||||
Print last result includes tracebacks:
|
||||
|
||||
#### `ExecutionContext`
|
||||
Tracks metadata, arguments, timing, and results for each action execution.
|
||||
|
||||
#### `HookManager`
|
||||
Registers and triggers lifecycle hooks (`before`, `after`, `on_error`, etc.) for actions and commands.
|
||||
```bash
|
||||
> history --last-result
|
||||
Command(key='R', description='Run My Pipeline' action='ChainedAction(name=my_pipeline, actions=['step_1', 'step_2'],
|
||||
args=(), kwargs={}, auto_inject=False, return_list=False)') ():
|
||||
Traceback (most recent call last):
|
||||
File ".../falyx/command.py", line 291, in __call__
|
||||
result = await self.action(*combined_args, **combined_kwargs)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File ".../falyx/action/base_action.py", line 91, in __call__
|
||||
return await self._run(*args, **kwargs)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File ".../falyx/action/chained_action.py", line 212, in _run
|
||||
result = await prepared(*combined_args, **updated_kwargs)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File ".../falyx/action/base_action.py", line 91, in __call__
|
||||
return await self._run(*args, **kwargs)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File ".../falyx/action/action.py", line 157, in _run
|
||||
result = await self.action(*combined_args, **combined_kwargs)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File ".../falyx/examples/simple.py", line 15, in flaky_step
|
||||
raise RuntimeError("Random failure!")
|
||||
RuntimeError: Random failure!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@ -163,6 +222,6 @@ Registers and triggers lifecycle hooks (`before`, `after`, `on_error`, etc.) for
|
||||
|
||||
> “Like a phalanx: organized, resilient, and reliable.”
|
||||
|
||||
Falyx is designed for developers who 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**.
|
||||
|
||||
---
|
||||
|
@ -21,11 +21,13 @@ async def test_args(
|
||||
service: str,
|
||||
place: Place = Place.NEW_YORK,
|
||||
region: str = "us-east-1",
|
||||
tag: str | None = None,
|
||||
verbose: bool | None = None,
|
||||
number: int | None = None,
|
||||
) -> str:
|
||||
if verbose:
|
||||
print(f"Deploying {service} to {region} at {place}...")
|
||||
return f"{service} deployed to {region} at {place}"
|
||||
print(f"Deploying {service}:{tag}:{number} to {region} at {place}...")
|
||||
return f"{service}:{tag}:{number} deployed to {region} at {place}"
|
||||
|
||||
|
||||
def default_config(parser: CommandArgumentParser) -> None:
|
||||
@ -55,9 +57,33 @@ def default_config(parser: CommandArgumentParser) -> None:
|
||||
action="store_bool_optional",
|
||||
help="Enable verbose output.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tag",
|
||||
type=str,
|
||||
help="Optional tag for the deployment.",
|
||||
suggestions=["latest", "stable", "beta"],
|
||||
)
|
||||
parser.add_argument(
|
||||
"--number",
|
||||
type=int,
|
||||
help="Optional number argument.",
|
||||
)
|
||||
parser.add_tldr_examples(
|
||||
[
|
||||
("web", "Deploy 'web' to the default location (New York)"),
|
||||
("cache London --tag beta", "Deploy 'cache' to London with tag"),
|
||||
("database --region us-west-2 --verbose", "Verbose deploy to west region"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
flx = Falyx("Argument Examples")
|
||||
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",
|
||||
@ -68,6 +94,7 @@ flx.add_command(
|
||||
name="test_args",
|
||||
action=test_args,
|
||||
),
|
||||
style="bold #B3EBF2",
|
||||
argument_config=default_config,
|
||||
)
|
||||
|
||||
|
@ -70,7 +70,7 @@ async def build_chain(dogs: list[Dog]) -> ChainedAction:
|
||||
),
|
||||
ConfirmAction(
|
||||
name="test_confirm",
|
||||
message="Do you want to process the dogs?",
|
||||
prompt_message="Do you want to process the dogs?",
|
||||
confirm_type="yes_no_cancel",
|
||||
return_last_result=True,
|
||||
inject_into="dogs",
|
||||
@ -101,10 +101,16 @@ def dog_config(parser: CommandArgumentParser) -> None:
|
||||
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")
|
||||
flx = Falyx("Save Dogs Example", program="confirm_example.py")
|
||||
|
||||
flx.add_command(
|
||||
key="D",
|
||||
|
@ -84,7 +84,7 @@ async def main() -> None:
|
||||
|
||||
# --- Bottom bar info ---
|
||||
flx.bottom_bar.columns = 3
|
||||
flx.bottom_bar.add_toggle_from_option("V", "Verbose", flx.options, "verbose")
|
||||
flx.bottom_bar.add_toggle_from_option("B", "Verbose", flx.options, "verbose")
|
||||
flx.bottom_bar.add_toggle_from_option("U", "Debug Hooks", flx.options, "debug_hooks")
|
||||
flx.bottom_bar.add_static("Version", f"Falyx v{__version__}")
|
||||
|
||||
|
@ -19,6 +19,8 @@ flx = Falyx(
|
||||
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(
|
||||
|
@ -1,59 +1,92 @@
|
||||
import asyncio
|
||||
import random
|
||||
import time
|
||||
|
||||
from falyx import ExecutionRegistry as er
|
||||
from falyx import Falyx
|
||||
from falyx.action import Action, ActionGroup, ChainedAction, ProcessAction
|
||||
from falyx.retry import RetryHandler, RetryPolicy
|
||||
from falyx.console import console
|
||||
|
||||
|
||||
# Step 1: Fast I/O-bound setup (standard Action)
|
||||
async def checkout_code():
|
||||
print("📥 Checking out code...")
|
||||
console.print("🔄 Checking out code...")
|
||||
await asyncio.sleep(0.5)
|
||||
console.print("📦 Code checked out successfully.")
|
||||
|
||||
|
||||
# Step 2: CPU-bound task (ProcessAction)
|
||||
def run_static_analysis():
|
||||
print("🧠 Running static analysis (CPU-bound)...")
|
||||
total = 0
|
||||
for i in range(10_000_000):
|
||||
total += i % 3
|
||||
time.sleep(2)
|
||||
return total
|
||||
|
||||
|
||||
# Step 3: Simulated flaky test with retry
|
||||
async def flaky_tests():
|
||||
import random
|
||||
|
||||
console.print("🧪 Running tests...")
|
||||
await asyncio.sleep(0.3)
|
||||
if random.random() < 0.3:
|
||||
raise RuntimeError("❌ Random test failure!")
|
||||
print("🧪 Tests passed.")
|
||||
console.print("🧪 Tests passed.")
|
||||
return "ok"
|
||||
|
||||
|
||||
# Step 4: Multiple deploy targets (parallel ActionGroup)
|
||||
async def deploy_to(target: str):
|
||||
print(f"🚀 Deploying to {target}...")
|
||||
await asyncio.sleep(0.2)
|
||||
console.print(f"🚀 Deploying to {target}...")
|
||||
await asyncio.sleep(random.randint(2, 6))
|
||||
console.print(f"✅ Deployment to {target} complete.")
|
||||
return f"{target} complete"
|
||||
|
||||
|
||||
def build_pipeline():
|
||||
retry_handler = RetryHandler(RetryPolicy(max_retries=3, delay=0.5))
|
||||
|
||||
# Base actions
|
||||
checkout = Action("Checkout", checkout_code)
|
||||
analysis = ProcessAction("Static Analysis", run_static_analysis)
|
||||
tests = Action("Run Tests", flaky_tests)
|
||||
tests.hooks.register("on_error", retry_handler.retry_on_error)
|
||||
analysis = ProcessAction(
|
||||
"Static Analysis",
|
||||
run_static_analysis,
|
||||
spinner=True,
|
||||
spinner_message="Analyzing code...",
|
||||
)
|
||||
analysis.hooks.register(
|
||||
"before", lambda ctx: console.print("🧠 Running static analysis (CPU-bound)...")
|
||||
)
|
||||
analysis.hooks.register("after", lambda ctx: console.print("🧠 Analysis complete!"))
|
||||
tests = Action(
|
||||
"Run Tests",
|
||||
flaky_tests,
|
||||
retry=True,
|
||||
spinner=True,
|
||||
spinner_message="Running tests...",
|
||||
)
|
||||
|
||||
# Parallel deploys
|
||||
deploy_group = ActionGroup(
|
||||
"Deploy to All",
|
||||
[
|
||||
Action("Deploy US", deploy_to, args=("us-west",)),
|
||||
Action("Deploy EU", deploy_to, args=("eu-central",)),
|
||||
Action("Deploy Asia", deploy_to, args=("asia-east",)),
|
||||
Action(
|
||||
"Deploy US",
|
||||
deploy_to,
|
||||
args=("us-west",),
|
||||
spinner=True,
|
||||
spinner_message="Deploying US...",
|
||||
),
|
||||
Action(
|
||||
"Deploy EU",
|
||||
deploy_to,
|
||||
args=("eu-central",),
|
||||
spinner=True,
|
||||
spinner_message="Deploying EU...",
|
||||
),
|
||||
Action(
|
||||
"Deploy Asia",
|
||||
deploy_to,
|
||||
args=("asia-east",),
|
||||
spinner=True,
|
||||
spinner_message="Deploying Asia...",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@ -66,10 +99,22 @@ pipeline = build_pipeline()
|
||||
|
||||
# Run the pipeline
|
||||
async def main():
|
||||
pipeline = build_pipeline()
|
||||
await pipeline()
|
||||
er.summary()
|
||||
await pipeline.preview()
|
||||
|
||||
flx = Falyx(
|
||||
hide_menu_table=True, program="pipeline_demo.py", show_placeholder_menu=True
|
||||
)
|
||||
flx.add_command(
|
||||
"P",
|
||||
"Run Pipeline",
|
||||
pipeline,
|
||||
spinner=True,
|
||||
spinner_type="line",
|
||||
spinner_message="Running pipeline...",
|
||||
tags=["pipeline", "demo"],
|
||||
help_text="Run the full CI/CD pipeline demo.",
|
||||
)
|
||||
|
||||
await flx.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
@ -11,15 +11,15 @@ setup_logging()
|
||||
# A flaky async step that fails randomly
|
||||
async def flaky_step() -> str:
|
||||
await asyncio.sleep(0.2)
|
||||
if random.random() < 0.3:
|
||||
if random.random() < 0.5:
|
||||
raise RuntimeError("Random failure!")
|
||||
print("Flaky step succeeded!")
|
||||
print("ok")
|
||||
return "ok"
|
||||
|
||||
|
||||
# Create a retry handler
|
||||
step1 = Action(name="step_1", action=flaky_step, retry=True)
|
||||
step2 = Action(name="step_2", action=flaky_step, retry=True)
|
||||
step1 = Action(name="step_1", action=flaky_step)
|
||||
step2 = Action(name="step_2", action=flaky_step)
|
||||
|
||||
# Chain the actions
|
||||
chain = ChainedAction(name="my_pipeline", actions=[step1, step2])
|
||||
@ -33,6 +33,8 @@ falyx.add_command(
|
||||
logging_hooks=True,
|
||||
preview_before_confirm=True,
|
||||
confirm=True,
|
||||
retry_all=True,
|
||||
spinner=True,
|
||||
)
|
||||
|
||||
# Entry point
|
||||
|
@ -22,7 +22,7 @@ chain = ChainedAction(
|
||||
"Name",
|
||||
UserInputAction(
|
||||
name="User Input",
|
||||
prompt_text="Enter your {last_result}: ",
|
||||
prompt_message="Enter your {last_result}: ",
|
||||
validator=validate_alpha(),
|
||||
),
|
||||
Action(
|
||||
|
@ -1,5 +1,38 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""action.py"""
|
||||
"""
|
||||
Defines `Action`, the core atomic unit in the Falyx CLI framework, used to wrap and
|
||||
execute a single callable or coroutine with structured lifecycle support.
|
||||
|
||||
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.
|
||||
|
||||
Key Features:
|
||||
- Lifecycle hooks: `before`, `on_success`, `on_error`, `after`, `on_teardown`
|
||||
- Optional `last_result` injection for chained workflows
|
||||
- Retry logic via configurable `RetryPolicy` and `RetryHandler`
|
||||
- Rollback function support for recovery and undo behavior
|
||||
- Rich preview output for introspection and dry-run diagnostics
|
||||
|
||||
Usage Scenarios:
|
||||
- Wrapping business logic, utility functions, or external API calls
|
||||
- Converting lightweight callables into structured CLI actions
|
||||
- Composing workflows using `Action`, `ChainedAction`, or `ActionGroup`
|
||||
|
||||
Example:
|
||||
def compute(x, y):
|
||||
return x + y
|
||||
|
||||
Action(
|
||||
name="AddNumbers",
|
||||
action=compute,
|
||||
args=(2, 3),
|
||||
)
|
||||
|
||||
This module serves as the foundation for building robust, observable,
|
||||
and composable CLI automation flows in Falyx.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Awaitable, Callable
|
||||
@ -27,11 +60,11 @@ class Action(BaseAction):
|
||||
- Optional rollback handlers for undo logic.
|
||||
|
||||
Args:
|
||||
name (str): Name of the action.
|
||||
name (str): Name of the action. Used for logging and debugging.
|
||||
action (Callable): The function or coroutine to execute.
|
||||
rollback (Callable, optional): Rollback function to undo the action.
|
||||
args (tuple, optional): Static positional arguments.
|
||||
kwargs (dict, optional): Static keyword arguments.
|
||||
args (tuple, optional): Positional arguments.
|
||||
kwargs (dict, optional): Keyword arguments.
|
||||
hooks (HookManager, optional): Hook manager for lifecycle events.
|
||||
inject_last_result (bool, optional): Enable last_result injection.
|
||||
inject_into (str, optional): Name of injected key.
|
||||
@ -50,14 +83,28 @@ class Action(BaseAction):
|
||||
hooks: HookManager | None = None,
|
||||
inject_last_result: bool = False,
|
||||
inject_into: str = "last_result",
|
||||
never_prompt: bool | None = None,
|
||||
logging_hooks: bool = False,
|
||||
retry: bool = False,
|
||||
retry_policy: RetryPolicy | None = None,
|
||||
spinner: bool = False,
|
||||
spinner_message: str = "Processing...",
|
||||
spinner_type: str = "dots",
|
||||
spinner_style: str = OneColors.CYAN,
|
||||
spinner_speed: float = 1.0,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
name,
|
||||
hooks=hooks,
|
||||
inject_last_result=inject_last_result,
|
||||
inject_into=inject_into,
|
||||
never_prompt=never_prompt,
|
||||
logging_hooks=logging_hooks,
|
||||
spinner=spinner,
|
||||
spinner_message=spinner_message,
|
||||
spinner_type=spinner_type,
|
||||
spinner_style=spinner_style,
|
||||
spinner_speed=spinner_speed,
|
||||
)
|
||||
self.action = action
|
||||
self.rollback = rollback
|
||||
@ -157,6 +204,7 @@ class Action(BaseAction):
|
||||
return (
|
||||
f"Action(name={self.name!r}, action="
|
||||
f"{getattr(self._action, '__name__', repr(self._action))}, "
|
||||
f"args={self.args!r}, kwargs={self.kwargs!r}, "
|
||||
f"retry={self.retry_policy.enabled}, "
|
||||
f"rollback={self.rollback is not None})"
|
||||
)
|
||||
|
@ -1,5 +1,36 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""action_factory_action.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 rich.tree import Tree
|
||||
@ -22,10 +53,14 @@ class ActionFactory(BaseAction):
|
||||
where the structure of the next action depends on runtime values.
|
||||
|
||||
Args:
|
||||
name (str): Name of the action.
|
||||
name (str): Name of the action. Used for logging and debugging.
|
||||
factory (Callable): A function that returns a BaseAction given args/kwargs.
|
||||
inject_last_result (bool): Whether to inject last_result into the factory.
|
||||
inject_into (str): The name of the kwarg to inject last_result as.
|
||||
args (tuple, optional): Positional arguments for the factory.
|
||||
kwargs (dict, optional): Keyword arguments for the factory.
|
||||
preview_args (tuple, optional): Positional arguments for the preview.
|
||||
preview_kwargs (dict, optional): Keyword arguments for the preview.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@ -133,3 +168,11 @@ class ActionFactory(BaseAction):
|
||||
|
||||
if not parent:
|
||||
self.console.print(tree)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f"ActionFactory(name={self.name!r}, "
|
||||
f"inject_last_result={self.inject_last_result}, "
|
||||
f"factory={self._factory.__name__ if hasattr(self._factory, '__name__') else type(self._factory).__name__}, "
|
||||
f"args={self.args!r}, kwargs={self.kwargs!r})"
|
||||
)
|
||||
|
@ -1,5 +1,39 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""action_group.py"""
|
||||
"""
|
||||
Defines `ActionGroup`, a Falyx Action that executes multiple sub-actions concurrently
|
||||
using asynchronous parallelism.
|
||||
|
||||
`ActionGroup` is designed for workflows where several independent actions can run
|
||||
simultaneously to improve responsiveness and reduce latency. It ensures robust error
|
||||
isolation, shared result tracking, and full lifecycle hook integration while preserving
|
||||
Falyx's introspectability and chaining capabilities.
|
||||
|
||||
Key Features:
|
||||
- Executes all actions in parallel via `asyncio.gather`
|
||||
- Aggregates results as a list of `(name, result)` tuples
|
||||
- Collects and reports multiple errors without interrupting execution
|
||||
- Compatible with `SharedContext`, `OptionsManager`, and `last_result` injection
|
||||
- Teardown-aware: propagates teardown registration across all child actions
|
||||
- Fully previewable via Rich tree rendering
|
||||
|
||||
Use Cases:
|
||||
- Batch execution of independent tasks (e.g., multiple file operations, API calls)
|
||||
- Concurrent report generation or validations
|
||||
- High-throughput CLI pipelines where latency is critical
|
||||
|
||||
Raises:
|
||||
- `EmptyGroupError`: If no actions are added to the group
|
||||
- `Exception`: Summarizes all failed actions after execution
|
||||
|
||||
Example:
|
||||
ActionGroup(
|
||||
name="ParallelChecks",
|
||||
actions=[Action(...), Action(...), ChainedAction(...)],
|
||||
)
|
||||
|
||||
This module complements `ChainedAction` by offering breadth-wise (parallel) execution
|
||||
as opposed to depth-wise (sequential) execution.
|
||||
"""
|
||||
import asyncio
|
||||
import random
|
||||
from typing import Any, Awaitable, Callable, Sequence
|
||||
@ -47,6 +81,8 @@ class ActionGroup(BaseAction, ActionListMixin):
|
||||
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.
|
||||
@ -65,12 +101,26 @@ class ActionGroup(BaseAction, ActionListMixin):
|
||||
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
|
||||
@ -191,7 +241,8 @@ class ActionGroup(BaseAction, ActionListMixin):
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"ActionGroup(name={self.name!r}, actions={[a.name for a in self.actions]!r},"
|
||||
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!r})"
|
||||
f"inject_into={self.inject_into})"
|
||||
)
|
||||
|
@ -1,12 +1,35 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""action_mixins.py"""
|
||||
"""
|
||||
Provides reusable mixins for managing collections of `BaseAction` instances
|
||||
within composite Falyx actions such as `ActionGroup` or `ChainedAction`.
|
||||
|
||||
The primary export, `ActionListMixin`, encapsulates common functionality for
|
||||
maintaining a mutable list of named actions—such as adding, removing, or retrieving
|
||||
actions by name—without duplicating logic across composite action types.
|
||||
"""
|
||||
|
||||
from typing import Sequence
|
||||
|
||||
from falyx.action.base_action import BaseAction
|
||||
|
||||
|
||||
class ActionListMixin:
|
||||
"""Mixin for managing a list of actions."""
|
||||
"""
|
||||
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] = []
|
||||
@ -22,7 +45,7 @@ class ActionListMixin:
|
||||
self.actions.append(action)
|
||||
|
||||
def remove_action(self, name: str) -> None:
|
||||
"""Removes an action by name."""
|
||||
"""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:
|
||||
@ -30,7 +53,7 @@ class ActionListMixin:
|
||||
return any(action.name == name for action in self.actions)
|
||||
|
||||
def get_action(self, name: str) -> BaseAction | None:
|
||||
"""Retrieves an action by name."""
|
||||
"""Retrieves a single action with the given name."""
|
||||
for action in self.actions:
|
||||
if action.name == name:
|
||||
return action
|
||||
|
@ -1,12 +1,53 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""action_types.py"""
|
||||
"""
|
||||
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):
|
||||
"""Enum for file return types."""
|
||||
"""
|
||||
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"
|
||||
@ -17,6 +58,11 @@ class FileType(Enum):
|
||||
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 = {
|
||||
@ -29,18 +75,38 @@ class FileType(Enum):
|
||||
|
||||
@classmethod
|
||||
def _missing_(cls, value: object) -> FileType:
|
||||
if isinstance(value, str):
|
||||
normalized = value.lower()
|
||||
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 FileType: '{value}'. Must be one of: {valid}")
|
||||
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):
|
||||
"""Enum for dictionary return types."""
|
||||
"""
|
||||
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"
|
||||
@ -48,14 +114,54 @@ class SelectionReturnType(Enum):
|
||||
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 DictReturnType: '{value}'. Must be one of: {valid}")
|
||||
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 different confirmation types."""
|
||||
"""
|
||||
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"
|
||||
@ -70,15 +176,30 @@ class ConfirmType(Enum):
|
||||
"""Return a list of all hook type choices."""
|
||||
return list(cls)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return the string representation of the confirm type."""
|
||||
return self.value
|
||||
@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 isinstance(value, str):
|
||||
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 == value.lower():
|
||||
if member.value == alias:
|
||||
return member
|
||||
valid = ", ".join(member.value for member in cls)
|
||||
raise ValueError(f"Invalid ConfirmType: '{value}'. Must be one of: {valid}")
|
||||
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
|
||||
|
@ -1,6 +1,5 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""base_action.py
|
||||
|
||||
"""
|
||||
Core action system for Falyx.
|
||||
|
||||
This module defines the building blocks for executable actions and workflows,
|
||||
@ -40,8 +39,10 @@ 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):
|
||||
@ -50,10 +51,16 @@ class BaseAction(ABC):
|
||||
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__(
|
||||
@ -63,8 +70,14 @@ class BaseAction(ABC):
|
||||
hooks: HookManager | None = None,
|
||||
inject_last_result: bool = False,
|
||||
inject_into: str = "last_result",
|
||||
never_prompt: bool = False,
|
||||
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()
|
||||
@ -72,10 +85,19 @@ class BaseAction(ABC):
|
||||
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._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)
|
||||
@ -122,7 +144,16 @@ class BaseAction(ABC):
|
||||
|
||||
@property
|
||||
def never_prompt(self) -> bool:
|
||||
return self.get_option("never_prompt", self._never_prompt)
|
||||
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
|
||||
|
@ -1,5 +1,69 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""chained_action.py"""
|
||||
"""
|
||||
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
|
||||
@ -35,8 +99,10 @@ class ChainedAction(BaseAction, ActionListMixin):
|
||||
previous results.
|
||||
|
||||
Args:
|
||||
name (str): Name of the chain.
|
||||
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.
|
||||
@ -61,12 +127,26 @@ class ChainedAction(BaseAction, ActionListMixin):
|
||||
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
|
||||
@ -235,7 +315,8 @@ class ChainedAction(BaseAction, ActionListMixin):
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"ChainedAction(name={self.name!r}, "
|
||||
f"actions={[a.name for a in self.actions]!r}, "
|
||||
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})"
|
||||
)
|
||||
|
@ -1,3 +1,43 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""
|
||||
Defines `ConfirmAction`, a Falyx Action that prompts the user for confirmation
|
||||
before continuing execution.
|
||||
|
||||
`ConfirmAction` supports a wide range of confirmation strategies, including:
|
||||
- Yes/No-style prompts
|
||||
- OK/Cancel dialogs
|
||||
- Typed confirmation (e.g., "CONFIRM" or "DELETE")
|
||||
- Acknowledge-only flows
|
||||
|
||||
It is useful for adding safety gates, user-driven approval steps, or destructive
|
||||
operation guards in CLI workflows. This Action supports both interactive use and
|
||||
non-interactive (headless) behavior via `never_prompt`, as well as full hook lifecycle
|
||||
integration and optional result passthrough.
|
||||
|
||||
Key Features:
|
||||
- Supports all common confirmation types (see `ConfirmType`)
|
||||
- Integrates with `PromptSession` for prompt_toolkit-based UX
|
||||
- Configurable fallback word validation and behavior on cancel
|
||||
- Can return the injected `last_result` instead of a boolean
|
||||
- Fully compatible with Falyx hooks, preview, and result injection
|
||||
|
||||
Use Cases:
|
||||
- Safety checks before deleting, pushing, or overwriting resources
|
||||
- Gatekeeping interactive workflows
|
||||
- Validating irreversible or sensitive operations
|
||||
|
||||
Example:
|
||||
ConfirmAction(
|
||||
name="ConfirmDeploy",
|
||||
message="Are you sure you want to deploy to production?",
|
||||
confirm_type="yes_no_cancel",
|
||||
)
|
||||
|
||||
Raises:
|
||||
- `CancelSignal`: When the user chooses to abort the action
|
||||
- `ValueError`: If an invalid `confirm_type` is provided
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
@ -11,7 +51,11 @@ 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, should_prompt_user
|
||||
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
|
||||
@ -30,8 +74,8 @@ class ConfirmAction(BaseAction):
|
||||
with an operation.
|
||||
|
||||
Attributes:
|
||||
name (str): Name of the action.
|
||||
message (str): The confirmation message to display.
|
||||
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.
|
||||
@ -44,7 +88,7 @@ class ConfirmAction(BaseAction):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
message: str = "Confirm?",
|
||||
prompt_message: str = "Confirm?",
|
||||
confirm_type: ConfirmType | str = ConfirmType.YES_NO,
|
||||
prompt_session: PromptSession | None = None,
|
||||
never_prompt: bool = False,
|
||||
@ -71,34 +115,34 @@ class ConfirmAction(BaseAction):
|
||||
inject_into=inject_into,
|
||||
never_prompt=never_prompt,
|
||||
)
|
||||
self.message = message
|
||||
self.confirm_type = self._coerce_confirm_type(confirm_type)
|
||||
self.prompt_session = prompt_session or PromptSession()
|
||||
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
|
||||
|
||||
def _coerce_confirm_type(self, confirm_type: ConfirmType | str) -> ConfirmType:
|
||||
"""Coerce the confirm_type to a ConfirmType enum."""
|
||||
if isinstance(confirm_type, ConfirmType):
|
||||
return confirm_type
|
||||
elif isinstance(confirm_type, str):
|
||||
return ConfirmType(confirm_type)
|
||||
return ConfirmType(confirm_type)
|
||||
|
||||
async def _confirm(self) -> bool:
|
||||
"""Confirm the action with the user."""
|
||||
match self.confirm_type:
|
||||
case ConfirmType.YES_NO:
|
||||
return await confirm_async(
|
||||
self.message,
|
||||
prefix="❓ ",
|
||||
suffix=" [Y/n] > ",
|
||||
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(
|
||||
f"❓ {self.message} [Y]es, [N]o, or [C]ancel to abort > ",
|
||||
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
|
||||
),
|
||||
@ -108,13 +152,19 @@ class ConfirmAction(BaseAction):
|
||||
return answer.upper() == "Y"
|
||||
case ConfirmType.TYPE_WORD:
|
||||
answer = await self.prompt_session.prompt_async(
|
||||
f"❓ {self.message} [{self.word}] to confirm or [N/n] > ",
|
||||
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(
|
||||
f"❓ {self.message} [{self.word}] to confirm or [N/n] > ",
|
||||
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":
|
||||
@ -122,9 +172,11 @@ class ConfirmAction(BaseAction):
|
||||
return answer.upper().strip() == self.word.upper().strip()
|
||||
case ConfirmType.YES_CANCEL:
|
||||
answer = await confirm_async(
|
||||
self.message,
|
||||
prefix="❓ ",
|
||||
suffix=" [Y/n] > ",
|
||||
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:
|
||||
@ -133,7 +185,10 @@ class ConfirmAction(BaseAction):
|
||||
case ConfirmType.OK_CANCEL:
|
||||
error_message = "Enter 'O', 'o' to confirm or 'C', 'c' to abort."
|
||||
answer = await self.prompt_session.prompt_async(
|
||||
f"❓ {self.message} [O]k to confirm, [C]ancel to abort > ",
|
||||
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":
|
||||
@ -141,7 +196,9 @@ class ConfirmAction(BaseAction):
|
||||
return answer.upper() == "O"
|
||||
case ConfirmType.ACKNOWLEDGE:
|
||||
answer = await self.prompt_session.prompt_async(
|
||||
f"❓ {self.message} [A]cknowledge > ",
|
||||
rich_text_to_prompt_text(
|
||||
f"❓ {self.prompt_message} [[{OneColors.CYAN_b}]A[/]]cknowledge > "
|
||||
),
|
||||
validator=word_validator("A"),
|
||||
)
|
||||
return answer.upper().strip() == "A"
|
||||
@ -200,7 +257,7 @@ class ConfirmAction(BaseAction):
|
||||
if not parent
|
||||
else parent.add(f"[{OneColors.CYAN_b}]ConfirmAction[/]: {self.name}")
|
||||
)
|
||||
tree.add(f"[bold]Message:[/] {self.message}")
|
||||
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):
|
||||
@ -210,6 +267,6 @@ class ConfirmAction(BaseAction):
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f"ConfirmAction(name={self.name}, message={self.message}, "
|
||||
f"ConfirmAction(name={self.name}, message={self.prompt_message}, "
|
||||
f"confirm_type={self.confirm_type}, return_last_result={self.return_last_result})"
|
||||
)
|
||||
|
@ -1,5 +1,41 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""fallback_action.py"""
|
||||
"""
|
||||
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
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""http_action.py
|
||||
"""
|
||||
Defines an Action subclass for making HTTP requests using aiohttp within Falyx workflows.
|
||||
|
||||
Features:
|
||||
@ -47,7 +47,7 @@ class HTTPAction(Action):
|
||||
- Retry and result injection compatible
|
||||
|
||||
Args:
|
||||
name (str): Name of the action.
|
||||
name (str): Name of the action. Used for logging and debugging.
|
||||
method (str): HTTP method (e.g., 'GET', 'POST').
|
||||
url (str): The request URL.
|
||||
headers (dict[str, str], optional): Request headers.
|
||||
@ -77,6 +77,11 @@ class HTTPAction(Action):
|
||||
inject_into: str = "last_result",
|
||||
retry: bool = False,
|
||||
retry_policy=None,
|
||||
spinner: bool = False,
|
||||
spinner_message: str = "Processing...",
|
||||
spinner_type: str = "dots",
|
||||
spinner_style: str = OneColors.CYAN,
|
||||
spinner_speed: float = 1.0,
|
||||
):
|
||||
self.method = method.upper()
|
||||
self.url = url
|
||||
@ -95,6 +100,11 @@ class HTTPAction(Action):
|
||||
inject_into=inject_into,
|
||||
retry=retry,
|
||||
retry_policy=retry_policy,
|
||||
spinner=spinner,
|
||||
spinner_message=spinner_message,
|
||||
spinner_type=spinner_type,
|
||||
spinner_style=spinner_style,
|
||||
spinner_speed=spinner_speed,
|
||||
)
|
||||
|
||||
async def _request(self, *_, **__) -> dict[str, Any]:
|
||||
|
@ -1,5 +1,5 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""io_action.py
|
||||
"""
|
||||
BaseIOAction: A base class for stream- or buffer-based IO-driven Actions.
|
||||
|
||||
This module defines `BaseIOAction`, a specialized variant of `BaseAction`
|
||||
@ -48,8 +48,11 @@ class BaseIOAction(BaseAction):
|
||||
- `to_output(data)`: Convert result into output string or bytes.
|
||||
- `_run(parsed_input, *args, **kwargs)`: Core execution logic.
|
||||
|
||||
Attributes:
|
||||
Args:
|
||||
name (str): Name of the action. Used for logging and debugging.
|
||||
hooks (HookManager | None): Hook manager for lifecycle events.
|
||||
mode (str): Either "buffered" or "stream". Controls input behavior.
|
||||
logging_hooks (bool): Whether to register debug hooks for logging.
|
||||
inject_last_result (bool): Whether to inject shared context input.
|
||||
"""
|
||||
|
||||
|
@ -1,5 +1,36 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""literal_input_action.py"""
|
||||
"""
|
||||
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
|
||||
|
@ -1,5 +1,41 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""load_file_action.py"""
|
||||
"""
|
||||
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
|
||||
@ -21,13 +57,58 @@ from falyx.themes import OneColors
|
||||
|
||||
|
||||
class LoadFileAction(BaseAction):
|
||||
"""LoadFileAction allows loading and parsing files of various types."""
|
||||
"""
|
||||
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",
|
||||
):
|
||||
@ -35,7 +116,8 @@ class LoadFileAction(BaseAction):
|
||||
name=name, inject_last_result=inject_last_result, inject_into=inject_into
|
||||
)
|
||||
self._file_path = self._coerce_file_path(file_path)
|
||||
self._file_type = self._coerce_file_type(file_type)
|
||||
self._file_type = FileType(file_type)
|
||||
self.encoding = encoding
|
||||
|
||||
@property
|
||||
def file_path(self) -> Path | None:
|
||||
@ -63,20 +145,6 @@ class LoadFileAction(BaseAction):
|
||||
"""Get the file type."""
|
||||
return self._file_type
|
||||
|
||||
@file_type.setter
|
||||
def file_type(self, value: FileType | str):
|
||||
"""Set the file type, converting to FileType if necessary."""
|
||||
self._file_type = self._coerce_file_type(value)
|
||||
|
||||
def _coerce_file_type(self, file_type: FileType | str) -> FileType:
|
||||
"""Coerce the file type to a FileType enum."""
|
||||
if isinstance(file_type, FileType):
|
||||
return file_type
|
||||
elif isinstance(file_type, str):
|
||||
return FileType(file_type)
|
||||
else:
|
||||
raise TypeError("file_type must be a FileType enum or string")
|
||||
|
||||
def get_infer_target(self) -> tuple[None, None]:
|
||||
return None, None
|
||||
|
||||
@ -91,27 +159,29 @@ class LoadFileAction(BaseAction):
|
||||
value: Any = None
|
||||
try:
|
||||
if self.file_type == FileType.TEXT:
|
||||
value = self.file_path.read_text(encoding="UTF-8")
|
||||
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="UTF-8"))
|
||||
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="UTF-8"))
|
||||
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="UTF-8"))
|
||||
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="UTF-8") as csvfile:
|
||||
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="UTF-8") as tsvfile:
|
||||
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="UTF-8"))
|
||||
tree = ET.parse(
|
||||
self.file_path, parser=ET.XMLParser(encoding=self.encoding)
|
||||
)
|
||||
root = tree.getroot()
|
||||
value = ET.tostring(root, encoding="unicode")
|
||||
value = root
|
||||
else:
|
||||
raise ValueError(f"Unsupported return type: {self.file_type}")
|
||||
|
||||
|
@ -1,5 +1,42 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""menu_action.py"""
|
||||
"""
|
||||
Defines `MenuAction`, a one-shot, interactive menu-style Falyx Action that presents
|
||||
a set of labeled options to the user and executes the corresponding action based on
|
||||
their selection.
|
||||
|
||||
Unlike the persistent top-level Falyx menu, `MenuAction` is intended for embedded,
|
||||
self-contained decision points within a workflow. It supports both interactive and
|
||||
non-interactive (headless) usage, integrates fully with the Falyx hook lifecycle,
|
||||
and allows optional defaulting or input injection from previous actions.
|
||||
|
||||
Each selectable item is defined in a `MenuOptionMap`, mapping a single-character or
|
||||
keyword to a `MenuOption`, which includes a description and a corresponding `BaseAction`.
|
||||
|
||||
Key Features:
|
||||
- Renders a Rich-powered multi-column menu table
|
||||
- Accepts custom prompt sessions or tables
|
||||
- Supports `last_result` injection for context-aware defaults
|
||||
- Gracefully handles `BackSignal` and `QuitSignal` for flow control
|
||||
- Compatible with preview trees and introspection tools
|
||||
|
||||
Use Cases:
|
||||
- In-workflow submenus or branches
|
||||
- Interactive control points in chained or grouped workflows
|
||||
- Configurable menus for multi-step user-driven automation
|
||||
|
||||
Example:
|
||||
MenuAction(
|
||||
name="SelectEnv",
|
||||
menu_options=MenuOptionMap(options={
|
||||
"D": MenuOption("Deploy to Dev", DeployDevAction()),
|
||||
"P": MenuOption("Deploy to Prod", DeployProdAction()),
|
||||
}),
|
||||
default_selection="D",
|
||||
)
|
||||
|
||||
This module is ideal for enabling structured, discoverable, and declarative
|
||||
menus in both interactive and programmatic CLI automation.
|
||||
"""
|
||||
from typing import Any
|
||||
|
||||
from prompt_toolkit import PromptSession
|
||||
@ -12,14 +49,65 @@ from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import HookType
|
||||
from falyx.logger import logger
|
||||
from falyx.menu import MenuOptionMap
|
||||
from falyx.prompt_utils import rich_text_to_prompt_text
|
||||
from falyx.selection import prompt_for_selection, render_table_base
|
||||
from falyx.signals import BackSignal, QuitSignal
|
||||
from falyx.signals import BackSignal, CancelSignal, QuitSignal
|
||||
from falyx.themes import OneColors
|
||||
from falyx.utils import chunks
|
||||
|
||||
|
||||
class MenuAction(BaseAction):
|
||||
"""MenuAction class for creating single use menu actions."""
|
||||
"""
|
||||
MenuAction displays a one-time interactive menu of predefined options,
|
||||
each mapped to a corresponding Action.
|
||||
|
||||
Unlike the main Falyx menu system, `MenuAction` is intended for scoped,
|
||||
self-contained selection logic—ideal for small in-flow menus, decision branches,
|
||||
or embedded control points in larger workflows.
|
||||
|
||||
Each selectable item is defined in a `MenuOptionMap`, which maps a string key
|
||||
to a `MenuOption`, bundling a description and a callable Action.
|
||||
|
||||
Key Features:
|
||||
- One-shot selection from labeled actions
|
||||
- Optional default or last_result-based selection
|
||||
- Full hook lifecycle (before, success, error, after, teardown)
|
||||
- Works with or without rendering a table (for headless use)
|
||||
- Compatible with `BackSignal` and `QuitSignal` for graceful control flow exits
|
||||
|
||||
Args:
|
||||
name (str): Name of the action. Used for logging and debugging.
|
||||
menu_options (MenuOptionMap): Mapping of keys to `MenuOption` objects.
|
||||
title (str): Table title displayed when prompting the user.
|
||||
columns (int): Number of columns in the rendered table.
|
||||
prompt_message (str): Prompt text displayed before selection.
|
||||
default_selection (str): Key to use if no user input is provided.
|
||||
inject_last_result (bool): Whether to inject `last_result` into args/kwargs.
|
||||
inject_into (str): Key under which to inject `last_result`.
|
||||
prompt_session (PromptSession | None): Custom session for Prompt Toolkit input.
|
||||
never_prompt (bool): If True, skips interaction and uses default or last_result.
|
||||
include_reserved (bool): Whether to include reserved keys (like 'X' for Exit).
|
||||
show_table (bool): Whether to render the Rich menu table.
|
||||
custom_table (Table | None): Pre-rendered Rich Table (bypasses auto-building).
|
||||
|
||||
Returns:
|
||||
Any: The result of the selected option's Action.
|
||||
|
||||
Raises:
|
||||
BackSignal: When the user chooses to return to a previous menu.
|
||||
QuitSignal: When the user chooses to exit the program.
|
||||
ValueError: If `never_prompt=True` but no default selection is resolvable.
|
||||
Exception: Any error raised during the execution of the selected Action.
|
||||
|
||||
Example:
|
||||
MenuAction(
|
||||
name="ChooseBranch",
|
||||
menu_options=MenuOptionMap(options={
|
||||
"A": MenuOption("Run analysis", ActionGroup(...)),
|
||||
"B": MenuOption("Run report", Action(...)),
|
||||
}),
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@ -47,9 +135,11 @@ class MenuAction(BaseAction):
|
||||
self.menu_options = menu_options
|
||||
self.title = title
|
||||
self.columns = columns
|
||||
self.prompt_message = prompt_message
|
||||
self.prompt_message = rich_text_to_prompt_text(prompt_message)
|
||||
self.default_selection = default_selection
|
||||
self.prompt_session = prompt_session or PromptSession()
|
||||
self.prompt_session = prompt_session or PromptSession(
|
||||
interrupt_exception=CancelSignal
|
||||
)
|
||||
self.include_reserved = include_reserved
|
||||
self.show_table = show_table
|
||||
self.custom_table = custom_table
|
||||
|
@ -1,5 +1,42 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""process_action.py"""
|
||||
"""
|
||||
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
|
||||
@ -47,12 +84,26 @@ class ProcessAction(BaseAction):
|
||||
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
|
||||
|
@ -1,5 +1,19 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""process_pool_action.py"""
|
||||
"""
|
||||
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
|
||||
@ -23,6 +37,21 @@ 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)
|
||||
@ -33,7 +62,44 @@ class ProcessTask:
|
||||
|
||||
|
||||
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,
|
||||
|
@ -1,5 +1,16 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""prompt_menu_action.py"""
|
||||
"""
|
||||
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
|
||||
@ -12,12 +23,59 @@ 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.signals import BackSignal, QuitSignal
|
||||
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):
|
||||
"""PromptMenuAction class for creating prompt -> actions."""
|
||||
"""
|
||||
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,
|
||||
@ -39,9 +97,11 @@ class PromptMenuAction(BaseAction):
|
||||
never_prompt=never_prompt,
|
||||
)
|
||||
self.menu_options = menu_options
|
||||
self.prompt_message = prompt_message
|
||||
self.prompt_message = rich_text_to_prompt_text(prompt_message)
|
||||
self.default_selection = default_selection
|
||||
self.prompt_session = prompt_session or PromptSession()
|
||||
self.prompt_session = prompt_session or PromptSession(
|
||||
interrupt_exception=CancelSignal
|
||||
)
|
||||
self.include_reserved = include_reserved
|
||||
|
||||
def get_infer_target(self) -> tuple[None, None]:
|
||||
|
@ -1,5 +1,25 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""save_file_action.py"""
|
||||
"""
|
||||
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
|
||||
@ -22,12 +42,50 @@ from falyx.themes import OneColors
|
||||
|
||||
class SaveFileAction(BaseAction):
|
||||
"""
|
||||
SaveFileAction saves data to a file in the specified format (e.g., TEXT, JSON, YAML).
|
||||
Supports overwrite control and integrates with chaining workflows via inject_last_result.
|
||||
Saves data to a file in the specified format.
|
||||
|
||||
Supported types: TEXT, JSON, YAML, TOML, CSV, TSV, XML
|
||||
`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.
|
||||
|
||||
If the file exists and overwrite is False, the action will raise a FileExistsError.
|
||||
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__(
|
||||
@ -36,6 +94,7 @@ class SaveFileAction(BaseAction):
|
||||
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,
|
||||
@ -50,6 +109,7 @@ class SaveFileAction(BaseAction):
|
||||
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.
|
||||
@ -60,11 +120,12 @@ class SaveFileAction(BaseAction):
|
||||
name=name, inject_last_result=inject_last_result, inject_into=inject_into
|
||||
)
|
||||
self._file_path = self._coerce_file_path(file_path)
|
||||
self._file_type = self._coerce_file_type(file_type)
|
||||
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:
|
||||
@ -92,20 +153,6 @@ class SaveFileAction(BaseAction):
|
||||
"""Get the file type."""
|
||||
return self._file_type
|
||||
|
||||
@file_type.setter
|
||||
def file_type(self, value: FileType | str):
|
||||
"""Set the file type, converting to FileType if necessary."""
|
||||
self._file_type = self._coerce_file_type(value)
|
||||
|
||||
def _coerce_file_type(self, file_type: FileType | str) -> FileType:
|
||||
"""Coerce the file type to a FileType enum."""
|
||||
if isinstance(file_type, FileType):
|
||||
return file_type
|
||||
elif isinstance(file_type, str):
|
||||
return FileType(file_type)
|
||||
else:
|
||||
raise TypeError("file_type must be a FileType enum or string")
|
||||
|
||||
def get_infer_target(self) -> tuple[None, None]:
|
||||
return None, None
|
||||
|
||||
@ -143,13 +190,15 @@ class SaveFileAction(BaseAction):
|
||||
|
||||
try:
|
||||
if self.file_type == FileType.TEXT:
|
||||
self.file_path.write_text(data, encoding="UTF-8")
|
||||
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="UTF-8")
|
||||
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="UTF-8")
|
||||
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="UTF-8")
|
||||
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
|
||||
@ -158,7 +207,7 @@ class SaveFileAction(BaseAction):
|
||||
f"{self.file_type.name} file type requires a list of lists"
|
||||
)
|
||||
with open(
|
||||
self.file_path, mode=self.mode, newline="", encoding="UTF-8"
|
||||
self.file_path, mode=self.mode, newline="", encoding=self.encoding
|
||||
) as csvfile:
|
||||
writer = csv.writer(csvfile)
|
||||
writer.writerows(data)
|
||||
@ -170,7 +219,7 @@ class SaveFileAction(BaseAction):
|
||||
f"{self.file_type.name} file type requires a list of lists"
|
||||
)
|
||||
with open(
|
||||
self.file_path, mode=self.mode, newline="", encoding="UTF-8"
|
||||
self.file_path, mode=self.mode, newline="", encoding=self.encoding
|
||||
) as tsvfile:
|
||||
writer = csv.writer(tsvfile, delimiter="\t")
|
||||
writer.writerows(data)
|
||||
@ -180,7 +229,7 @@ class SaveFileAction(BaseAction):
|
||||
root = ET.Element("root")
|
||||
self._dict_to_xml(data, root)
|
||||
tree = ET.ElementTree(root)
|
||||
tree.write(self.file_path, encoding="UTF-8", xml_declaration=True)
|
||||
tree.write(self.file_path, encoding=self.encoding, xml_declaration=True)
|
||||
else:
|
||||
raise ValueError(f"Unsupported file type: {self.file_type}")
|
||||
|
||||
|
@ -1,5 +1,47 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""select_file_action.py"""
|
||||
"""
|
||||
Defines `SelectFileAction`, a Falyx Action that allows users to select one or more
|
||||
files from a target directory and optionally return either their content or path,
|
||||
parsed based on a selected `FileType`.
|
||||
|
||||
This action combines rich interactive selection (via `SelectionOption`) with
|
||||
format-aware parsing, making it ideal for loading external resources, injecting
|
||||
config files, or dynamically selecting inputs mid-pipeline.
|
||||
|
||||
Supports filtering by file suffix, customizable prompt layout, multi-select mode,
|
||||
and automatic content parsing for common formats.
|
||||
|
||||
Key Features:
|
||||
- Lists files from a directory and renders them in a Rich-powered menu
|
||||
- Supports suffix filtering (e.g., only `.yaml` or `.json` files)
|
||||
- Returns content parsed as `str`, `dict`, `list`, or raw `Path` depending on `FileType`
|
||||
- Works in single or multi-selection mode
|
||||
- Fully compatible with Falyx hooks and context system
|
||||
- Graceful cancellation via `CancelSignal`
|
||||
|
||||
Supported Return Types (`FileType`):
|
||||
- `TEXT`: UTF-8 string content
|
||||
- `PATH`: File path object (`Path`)
|
||||
- `JSON`, `YAML`, `TOML`: Parsed dictionaries or lists
|
||||
- `CSV`, `TSV`: `list[list[str]]` from structured rows
|
||||
- `XML`: `ElementTree.Element` root object
|
||||
|
||||
Use Cases:
|
||||
- Prompting users to select a config file during setup
|
||||
- Dynamically loading data into chained workflows
|
||||
- CLI interfaces that require structured file ingestion
|
||||
|
||||
Example:
|
||||
SelectFileAction(
|
||||
name="ChooseConfigFile",
|
||||
directory="configs/",
|
||||
suffix_filter=".yaml",
|
||||
return_type="yaml",
|
||||
)
|
||||
|
||||
This module is ideal for use cases where file choice is deferred to runtime
|
||||
and needs to feed into structured automation pipelines.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
@ -19,6 +61,7 @@ from falyx.context import ExecutionContext
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import HookType
|
||||
from falyx.logger import logger
|
||||
from falyx.prompt_utils import rich_text_to_prompt_text
|
||||
from falyx.selection import (
|
||||
SelectionOption,
|
||||
prompt_for_selection,
|
||||
@ -30,7 +73,7 @@ from falyx.themes import OneColors
|
||||
|
||||
class SelectFileAction(BaseAction):
|
||||
"""
|
||||
SelectFileAction allows users to select a file from a directory and return:
|
||||
SelectFileAction allows users to select a file(s) from a directory and return:
|
||||
- file content (as text, JSON, CSV, etc.)
|
||||
- or the file path itself.
|
||||
|
||||
@ -50,6 +93,9 @@ class SelectFileAction(BaseAction):
|
||||
style (str): Style for the selection options.
|
||||
suffix_filter (str | None): Restrict to certain file types.
|
||||
return_type (FileType): What to return (path, content, parsed).
|
||||
number_selections (int | str): How many files to select (1, 2, '*').
|
||||
separator (str): Separator for multiple selections.
|
||||
allow_duplicates (bool): Allow selecting the same file multiple times.
|
||||
prompt_session (PromptSession | None): Prompt session for user input.
|
||||
"""
|
||||
|
||||
@ -64,6 +110,7 @@ class SelectFileAction(BaseAction):
|
||||
style: str = OneColors.WHITE,
|
||||
suffix_filter: str | None = None,
|
||||
return_type: FileType | str = FileType.PATH,
|
||||
encoding: str = "UTF-8",
|
||||
number_selections: int | str = 1,
|
||||
separator: str = ",",
|
||||
allow_duplicates: bool = False,
|
||||
@ -73,14 +120,17 @@ class SelectFileAction(BaseAction):
|
||||
self.directory = Path(directory).resolve()
|
||||
self.title = title
|
||||
self.columns = columns
|
||||
self.prompt_message = prompt_message
|
||||
self.prompt_message = rich_text_to_prompt_text(prompt_message)
|
||||
self.suffix_filter = suffix_filter
|
||||
self.style = style
|
||||
self.number_selections = number_selections
|
||||
self.separator = separator
|
||||
self.allow_duplicates = allow_duplicates
|
||||
self.prompt_session = prompt_session or PromptSession()
|
||||
self.return_type = self._coerce_return_type(return_type)
|
||||
self.prompt_session = prompt_session or PromptSession(
|
||||
interrupt_exception=CancelSignal
|
||||
)
|
||||
self.return_type = FileType(return_type)
|
||||
self.encoding = encoding
|
||||
|
||||
@property
|
||||
def number_selections(self) -> int | str:
|
||||
@ -97,50 +147,45 @@ class SelectFileAction(BaseAction):
|
||||
else:
|
||||
raise ValueError("number_selections must be a positive integer or one of '*'")
|
||||
|
||||
def _coerce_return_type(self, return_type: FileType | str) -> FileType:
|
||||
if isinstance(return_type, FileType):
|
||||
return return_type
|
||||
elif isinstance(return_type, str):
|
||||
return FileType(return_type)
|
||||
else:
|
||||
raise TypeError("return_type must be a FileType enum or string")
|
||||
|
||||
def get_options(self, files: list[Path]) -> dict[str, SelectionOption]:
|
||||
value: Any
|
||||
options = {}
|
||||
for index, file in enumerate(files):
|
||||
options[str(index)] = SelectionOption(
|
||||
description=file.name,
|
||||
value=file, # Store the Path only — parsing will happen later
|
||||
style=self.style,
|
||||
)
|
||||
return options
|
||||
|
||||
def parse_file(self, file: Path) -> Any:
|
||||
value: Any
|
||||
try:
|
||||
if self.return_type == FileType.TEXT:
|
||||
value = file.read_text(encoding="UTF-8")
|
||||
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="UTF-8"))
|
||||
value = json.loads(file.read_text(encoding=self.encoding))
|
||||
elif self.return_type == FileType.TOML:
|
||||
value = toml.loads(file.read_text(encoding="UTF-8"))
|
||||
value = toml.loads(file.read_text(encoding=self.encoding))
|
||||
elif self.return_type == FileType.YAML:
|
||||
value = yaml.safe_load(file.read_text(encoding="UTF-8"))
|
||||
value = yaml.safe_load(file.read_text(encoding=self.encoding))
|
||||
elif self.return_type == FileType.CSV:
|
||||
with open(file, newline="", encoding="UTF-8") as csvfile:
|
||||
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="UTF-8") as tsvfile:
|
||||
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="UTF-8"))
|
||||
root = tree.getroot()
|
||||
value = ET.tostring(root, encoding="unicode")
|
||||
tree = ET.parse(file, parser=ET.XMLParser(encoding=self.encoding))
|
||||
value = tree.getroot()
|
||||
else:
|
||||
raise ValueError(f"Unsupported return type: {self.return_type}")
|
||||
|
||||
options[str(index)] = SelectionOption(
|
||||
description=file.name, value=value, style=self.style
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error("Failed to parse %s: %s", file.name, error)
|
||||
return options
|
||||
return value
|
||||
|
||||
def _find_cancel_key(self, options) -> str:
|
||||
"""Return first numeric value not already used in the selection dict."""
|
||||
@ -199,9 +244,9 @@ class SelectFileAction(BaseAction):
|
||||
if isinstance(keys, str):
|
||||
if keys == cancel_key:
|
||||
raise CancelSignal("User canceled the selection.")
|
||||
result = options[keys].value
|
||||
result = self.parse_file(options[keys].value)
|
||||
elif isinstance(keys, list):
|
||||
result = [options[key].value for key in keys]
|
||||
result = [self.parse_file(options[key].value) for key in keys]
|
||||
|
||||
context.result = result
|
||||
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||
@ -217,7 +262,7 @@ class SelectFileAction(BaseAction):
|
||||
er.record(context)
|
||||
|
||||
async def preview(self, parent: Tree | None = None):
|
||||
label = f"[{OneColors.GREEN}]📁 SelectFilesAction[/] '{self.name}'"
|
||||
label = f"[{OneColors.GREEN}]📁 SelectFileAction[/] '{self.name}'"
|
||||
tree = parent.add(label) if parent else Tree(label)
|
||||
|
||||
tree.add(f"[dim]Directory:[/] {str(self.directory)}")
|
||||
@ -225,6 +270,7 @@ class SelectFileAction(BaseAction):
|
||||
tree.add(f"[dim]Return type:[/] {self.return_type}")
|
||||
tree.add(f"[dim]Prompt:[/] {self.prompt_message}")
|
||||
tree.add(f"[dim]Columns:[/] {self.columns}")
|
||||
tree.add("[dim]Loading:[/] Lazy (parsing occurs after selection)")
|
||||
try:
|
||||
files = list(self.directory.iterdir())
|
||||
if self.suffix_filter:
|
||||
@ -243,6 +289,6 @@ class SelectFileAction(BaseAction):
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f"SelectFilesAction(name={self.name!r}, dir={str(self.directory)!r}, "
|
||||
f"SelectFileAction(name={self.name!r}, dir={str(self.directory)!r}, "
|
||||
f"suffix_filter={self.suffix_filter!r}, return_type={self.return_type})"
|
||||
)
|
||||
|
@ -1,5 +1,36 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""selection_action.py"""
|
||||
"""
|
||||
Defines `SelectionAction`, a highly flexible Falyx Action for interactive or headless
|
||||
selection from a list or dictionary of user-defined options.
|
||||
|
||||
This module powers workflows that require prompting the user for input, selecting
|
||||
configuration presets, branching execution paths, or collecting multiple values
|
||||
in a type-safe, hook-compatible, and composable way.
|
||||
|
||||
Key Features:
|
||||
- Supports both flat lists and structured dictionaries (`SelectionOptionMap`)
|
||||
- Handles single or multi-selection with configurable separators
|
||||
- Returns results in various formats (key, value, description, item, or mapping)
|
||||
- Integrates fully with Falyx lifecycle hooks and `last_result` injection
|
||||
- Works in interactive (`prompt_toolkit`) and non-interactive (headless) modes
|
||||
- Renders a Rich-based table preview for diagnostics or dry runs
|
||||
|
||||
Usage Scenarios:
|
||||
- Guided CLI wizards or configuration menus
|
||||
- Dynamic branching or conditional step logic
|
||||
- User-driven parameterization in chained workflows
|
||||
- Reusable pickers for environments, files, datasets, etc.
|
||||
|
||||
Example:
|
||||
SelectionAction(
|
||||
name="ChooseMode",
|
||||
selections={"dev": "Development", "prod": "Production"},
|
||||
return_type="key"
|
||||
)
|
||||
|
||||
This module is foundational to creating expressive, user-centered CLI experiences
|
||||
within Falyx while preserving reproducibility and automation friendliness.
|
||||
"""
|
||||
from typing import Any
|
||||
|
||||
from prompt_toolkit import PromptSession
|
||||
@ -11,6 +42,7 @@ from falyx.context import ExecutionContext
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import HookType
|
||||
from falyx.logger import logger
|
||||
from falyx.prompt_utils import rich_text_to_prompt_text
|
||||
from falyx.selection import (
|
||||
SelectionOption,
|
||||
SelectionOptionMap,
|
||||
@ -25,11 +57,60 @@ from falyx.themes import OneColors
|
||||
|
||||
class SelectionAction(BaseAction):
|
||||
"""
|
||||
A selection action that prompts the user to select an option from a list or
|
||||
dictionary. The selected option is then returned as the result of the action.
|
||||
A Falyx Action for interactively or programmatically selecting one or more items
|
||||
from a list or dictionary of options.
|
||||
|
||||
If return_key is True, the key of the selected option is returned instead of
|
||||
the value.
|
||||
`SelectionAction` supports both `list[str]` and `dict[str, SelectionOption]`
|
||||
inputs. It renders a prompt (unless `never_prompt=True`), validates user input
|
||||
or injected defaults, and returns a structured result based on the specified
|
||||
`return_type`.
|
||||
|
||||
It is commonly used for item pickers, confirmation flows, dynamic parameterization,
|
||||
or guided workflows in interactive or headless CLI pipelines.
|
||||
|
||||
Features:
|
||||
- Supports single or multiple selections (`number_selections`)
|
||||
- Dictionary mode allows rich metadata (description, value, style)
|
||||
- Flexible return values: key(s), value(s), item(s), description(s), or mappings
|
||||
- Fully hookable lifecycle (`before`, `on_success`, `on_error`, `after`, `on_teardown`)
|
||||
- Default selection logic supports previous results (`last_result`)
|
||||
- Can run in headless mode using `never_prompt` and fallback defaults
|
||||
|
||||
Args:
|
||||
name (str): Action name for tracking and logging.
|
||||
selections (list[str] | dict[str, SelectionOption] | dict[str, Any]):
|
||||
The available choices. If a plain dict is passed, values are converted
|
||||
into `SelectionOption` instances.
|
||||
title (str): Title shown in the selection UI (default: "Select an option").
|
||||
columns (int): Number of columns in the selection table.
|
||||
prompt_message (str): Input prompt for the user (default: "Select > ").
|
||||
default_selection (str | list[str]): Key(s) or index(es) used as fallback selection.
|
||||
number_selections (int | str): Max number of choices allowed (or "*" for unlimited).
|
||||
separator (str): Character used to separate multi-selections (default: ",").
|
||||
allow_duplicates (bool): Whether duplicate selections are allowed.
|
||||
inject_last_result (bool): If True, attempts to inject the last result as default.
|
||||
inject_into (str): The keyword name for injected value (default: "last_result").
|
||||
return_type (SelectionReturnType | str): The type of result to return.
|
||||
prompt_session (PromptSession | None): Reused or customized prompt_toolkit session.
|
||||
never_prompt (bool): If True, skips prompting and uses default_selection or last_result.
|
||||
show_table (bool): Whether to render the selection table before prompting.
|
||||
|
||||
Returns:
|
||||
Any: The selected result(s), shaped according to `return_type`.
|
||||
|
||||
Raises:
|
||||
CancelSignal: If the user chooses the cancel option.
|
||||
ValueError: If configuration is invalid or no selection can be resolved.
|
||||
TypeError: If `selections` is not a supported type.
|
||||
|
||||
Example:
|
||||
SelectionAction(
|
||||
name="PickEnv",
|
||||
selections={"dev": "Development", "prod": "Production"},
|
||||
return_type="key",
|
||||
)
|
||||
|
||||
This Action supports use in both interactive menus and chained, non-interactive CLI flows.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@ -46,7 +127,7 @@ class SelectionAction(BaseAction):
|
||||
title: str = "Select an option",
|
||||
columns: int = 5,
|
||||
prompt_message: str = "Select > ",
|
||||
default_selection: str = "",
|
||||
default_selection: str | list[str] = "",
|
||||
number_selections: int | str = 1,
|
||||
separator: str = ",",
|
||||
allow_duplicates: bool = False,
|
||||
@ -65,15 +146,17 @@ class SelectionAction(BaseAction):
|
||||
)
|
||||
# Setter normalizes to correct type, mypy can't infer that
|
||||
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.columns = columns
|
||||
self.prompt_session = prompt_session or PromptSession()
|
||||
self.prompt_session = prompt_session or PromptSession(
|
||||
interrupt_exception=CancelSignal
|
||||
)
|
||||
self.default_selection = default_selection
|
||||
self.number_selections = number_selections
|
||||
self.separator = separator
|
||||
self.allow_duplicates = allow_duplicates
|
||||
self.prompt_message = prompt_message
|
||||
self.prompt_message = rich_text_to_prompt_text(prompt_message)
|
||||
self.show_table = show_table
|
||||
|
||||
@property
|
||||
@ -91,13 +174,6 @@ class SelectionAction(BaseAction):
|
||||
else:
|
||||
raise ValueError("number_selections must be a positive integer or '*'")
|
||||
|
||||
def _coerce_return_type(
|
||||
self, return_type: SelectionReturnType | str
|
||||
) -> SelectionReturnType:
|
||||
if isinstance(return_type, SelectionReturnType):
|
||||
return return_type
|
||||
return SelectionReturnType(return_type)
|
||||
|
||||
@property
|
||||
def selections(self) -> list[str] | SelectionOptionMap:
|
||||
return self._selections
|
||||
@ -202,6 +278,95 @@ class SelectionAction(BaseAction):
|
||||
raise ValueError(f"Unsupported return type: {self.return_type}")
|
||||
return result
|
||||
|
||||
async def _resolve_effective_default(self) -> str:
|
||||
effective_default: str | list[str] = self.default_selection
|
||||
maybe_result = self.last_result
|
||||
if self.number_selections == 1:
|
||||
if isinstance(effective_default, list):
|
||||
effective_default = effective_default[0] if effective_default else ""
|
||||
elif isinstance(maybe_result, list):
|
||||
maybe_result = maybe_result[0] if maybe_result else ""
|
||||
default = await self._resolve_single_default(maybe_result)
|
||||
if not default:
|
||||
default = await self._resolve_single_default(effective_default)
|
||||
if not default and self.inject_last_result:
|
||||
logger.warning(
|
||||
"[%s] Injected last result '%s' not found in selections",
|
||||
self.name,
|
||||
maybe_result,
|
||||
)
|
||||
return default
|
||||
|
||||
if maybe_result and isinstance(maybe_result, list):
|
||||
maybe_result = [
|
||||
await self._resolve_single_default(item) for item in maybe_result
|
||||
]
|
||||
if (
|
||||
maybe_result
|
||||
and self.number_selections != "*"
|
||||
and len(maybe_result) != self.number_selections
|
||||
):
|
||||
raise ValueError(
|
||||
f"[{self.name}] 'number_selections' is {self.number_selections}, "
|
||||
f"but last_result has a different length: {len(maybe_result)}."
|
||||
)
|
||||
return self.separator.join(maybe_result)
|
||||
elif effective_default and isinstance(effective_default, list):
|
||||
effective_default = [
|
||||
await self._resolve_single_default(item) for item in effective_default
|
||||
]
|
||||
if (
|
||||
effective_default
|
||||
and self.number_selections != "*"
|
||||
and len(effective_default) != self.number_selections
|
||||
):
|
||||
raise ValueError(
|
||||
f"[{self.name}] 'number_selections' is {self.number_selections}, "
|
||||
f"but default_selection has a different length: {len(effective_default)}."
|
||||
)
|
||||
return self.separator.join(effective_default)
|
||||
if self.inject_last_result:
|
||||
logger.warning(
|
||||
"[%s] Injected last result '%s' not found in selections",
|
||||
self.name,
|
||||
maybe_result,
|
||||
)
|
||||
return ""
|
||||
|
||||
async def _resolve_single_default(self, maybe_result: str) -> str:
|
||||
effective_default = ""
|
||||
if isinstance(self.selections, dict):
|
||||
if str(maybe_result) in self.selections:
|
||||
effective_default = str(maybe_result)
|
||||
elif maybe_result in (
|
||||
selection.value for selection in self.selections.values()
|
||||
):
|
||||
selection = [
|
||||
key
|
||||
for key, sel in self.selections.items()
|
||||
if sel.value == maybe_result
|
||||
]
|
||||
if selection:
|
||||
effective_default = selection[0]
|
||||
elif maybe_result in (
|
||||
selection.description for selection in self.selections.values()
|
||||
):
|
||||
selection = [
|
||||
key
|
||||
for key, sel in self.selections.items()
|
||||
if sel.description == maybe_result
|
||||
]
|
||||
if selection:
|
||||
effective_default = selection[0]
|
||||
elif isinstance(self.selections, list):
|
||||
if str(maybe_result).isdigit() and int(maybe_result) in range(
|
||||
len(self.selections)
|
||||
):
|
||||
effective_default = maybe_result
|
||||
elif maybe_result in self.selections:
|
||||
effective_default = str(self.selections.index(maybe_result))
|
||||
return effective_default
|
||||
|
||||
async def _run(self, *args, **kwargs) -> Any:
|
||||
kwargs = self._maybe_inject_last_result(kwargs)
|
||||
context = ExecutionContext(
|
||||
@ -211,28 +376,7 @@ class SelectionAction(BaseAction):
|
||||
action=self,
|
||||
)
|
||||
|
||||
effective_default = str(self.default_selection)
|
||||
maybe_result = str(self.last_result)
|
||||
if isinstance(self.selections, dict):
|
||||
if maybe_result in self.selections:
|
||||
effective_default = maybe_result
|
||||
elif self.inject_last_result:
|
||||
logger.warning(
|
||||
"[%s] Injected last result '%s' not found in selections",
|
||||
self.name,
|
||||
maybe_result,
|
||||
)
|
||||
elif isinstance(self.selections, list):
|
||||
if maybe_result.isdigit() and int(maybe_result) in range(
|
||||
len(self.selections)
|
||||
):
|
||||
effective_default = maybe_result
|
||||
elif self.inject_last_result:
|
||||
logger.warning(
|
||||
"[%s] Injected last result '%s' not found in selections",
|
||||
self.name,
|
||||
maybe_result,
|
||||
)
|
||||
effective_default = await self._resolve_effective_default()
|
||||
|
||||
if self.never_prompt and not effective_default:
|
||||
raise ValueError(
|
||||
@ -251,6 +395,9 @@ class SelectionAction(BaseAction):
|
||||
columns=self.columns,
|
||||
formatter=self.cancel_formatter,
|
||||
)
|
||||
if effective_default is None or isinstance(effective_default, int):
|
||||
effective_default = ""
|
||||
|
||||
if not self.never_prompt:
|
||||
indices: int | list[int] = await prompt_for_index(
|
||||
len(self.selections),
|
||||
@ -265,8 +412,13 @@ class SelectionAction(BaseAction):
|
||||
cancel_key=self.cancel_key,
|
||||
)
|
||||
else:
|
||||
if effective_default:
|
||||
if effective_default and self.number_selections == 1:
|
||||
indices = int(effective_default)
|
||||
elif effective_default:
|
||||
indices = [
|
||||
int(index)
|
||||
for index in effective_default.split(self.separator)
|
||||
]
|
||||
else:
|
||||
raise ValueError(
|
||||
f"[{self.name}] 'never_prompt' is True but no valid "
|
||||
@ -308,7 +460,15 @@ class SelectionAction(BaseAction):
|
||||
cancel_key=self.cancel_key,
|
||||
)
|
||||
else:
|
||||
if effective_default and self.number_selections == 1:
|
||||
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.")
|
||||
|
||||
@ -337,13 +497,13 @@ class SelectionAction(BaseAction):
|
||||
|
||||
if isinstance(self.selections, list):
|
||||
sub = tree.add(f"[dim]Type:[/] List[str] ({len(self.selections)} items)")
|
||||
for i, item in enumerate(self.selections[:10]): # limit to 10
|
||||
for i, item in enumerate(self.selections[:10]):
|
||||
sub.add(f"[dim]{i}[/]: {item}")
|
||||
if len(self.selections) > 10:
|
||||
sub.add(f"[dim]... ({len(self.selections) - 10} more)[/]")
|
||||
elif isinstance(self.selections, dict):
|
||||
sub = tree.add(
|
||||
f"[dim]Type:[/] Dict[str, (str, Any)] ({len(self.selections)} items)"
|
||||
f"[dim]Type:[/] Dict[str, SelectionOption] ({len(self.selections)} items)"
|
||||
)
|
||||
for i, (key, option) in enumerate(list(self.selections.items())[:10]):
|
||||
sub.add(f"[dim]{key}[/]: {option.description}")
|
||||
@ -353,9 +513,30 @@ class SelectionAction(BaseAction):
|
||||
tree.add(f"[{OneColors.DARK_RED_b}]Invalid selections type[/]")
|
||||
return
|
||||
|
||||
tree.add(f"[dim]Default:[/] '{self.default_selection or self.last_result}'")
|
||||
tree.add(f"[dim]Return:[/] {self.return_type.name.capitalize()}")
|
||||
default = self.default_selection or self.last_result
|
||||
if isinstance(default, list):
|
||||
default_display = self.separator.join(str(d) for d in default)
|
||||
else:
|
||||
default_display = str(default or "")
|
||||
|
||||
tree.add(f"[dim]Default:[/] '{default_display}'")
|
||||
|
||||
return_behavior = {
|
||||
"KEY": "selected key(s)",
|
||||
"VALUE": "mapped value(s)",
|
||||
"DESCRIPTION": "description(s)",
|
||||
"ITEMS": "SelectionOption object(s)",
|
||||
"DESCRIPTION_VALUE": "{description: value}",
|
||||
}.get(self.return_type.name, self.return_type.name)
|
||||
|
||||
tree.add(
|
||||
f"[dim]Return:[/] {self.return_type.name.capitalize()} → {return_behavior}"
|
||||
)
|
||||
tree.add(f"[dim]Prompt:[/] {'Disabled' if self.never_prompt else 'Enabled'}")
|
||||
tree.add(f"[dim]Columns:[/] {self.columns}")
|
||||
tree.add(
|
||||
f"[dim]Multi-select:[/] {'Yes' if self.number_selections != 1 else 'No'}"
|
||||
)
|
||||
|
||||
if not parent:
|
||||
self.console.print(tree)
|
||||
|
@ -1,6 +1,5 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""shell_action.py
|
||||
Execute shell commands with input substitution."""
|
||||
"""Execute shell commands with input substitution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
@ -1,32 +1,85 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""signal_action.py"""
|
||||
"""
|
||||
Defines `SignalAction`, a lightweight Falyx Action that raises a `FlowSignal`
|
||||
(such as `BackSignal`, `QuitSignal`, or `BreakChainSignal`) during execution to
|
||||
alter or exit the CLI flow.
|
||||
|
||||
Unlike traditional actions, `SignalAction` does not return a result—instead, it raises
|
||||
a signal to break, back out, or exit gracefully. Despite its minimal behavior,
|
||||
it fully supports Falyx's hook lifecycle, including `before`, `on_error`, `after`,
|
||||
and `on_teardown`—allowing it to trigger logging, audit events, UI updates, or custom
|
||||
telemetry before halting flow.
|
||||
|
||||
Key Features:
|
||||
- Declaratively raises a `FlowSignal` from within any Falyx workflow
|
||||
- Works in menus, chained actions, or conditionals
|
||||
- Hook-compatible: can run pre- and post-signal lifecycle hooks
|
||||
- Supports previewing and structured introspection
|
||||
|
||||
Use Cases:
|
||||
- Implementing "Back", "Cancel", or "Quit" options in `MenuAction` or `PromptMenuAction`
|
||||
- Triggering an intentional early exit from a `ChainedAction`
|
||||
- Running cleanup hooks before stopping execution
|
||||
|
||||
Example:
|
||||
SignalAction("ExitApp", QuitSignal(), hooks=my_hook_manager)
|
||||
"""
|
||||
from rich.tree import Tree
|
||||
|
||||
from falyx.action.action import Action
|
||||
from falyx.hook_manager import HookManager
|
||||
from falyx.signals import FlowSignal
|
||||
from falyx.themes import OneColors
|
||||
|
||||
|
||||
class SignalAction(Action):
|
||||
"""
|
||||
An action that raises a control flow signal when executed.
|
||||
A hook-compatible action that raises a control flow signal when invoked.
|
||||
|
||||
Useful for exiting a menu, going back, or halting execution gracefully.
|
||||
`SignalAction` raises a `FlowSignal` (e.g., `BackSignal`, `QuitSignal`,
|
||||
`BreakChainSignal`) during execution. It is commonly used to exit menus,
|
||||
break from chained actions, or halt workflows intentionally.
|
||||
|
||||
Even though the signal interrupts normal flow, all registered lifecycle hooks
|
||||
(`before`, `on_error`, `after`, `on_teardown`) are triggered as expected—
|
||||
allowing structured behavior such as logging, analytics, or UI changes
|
||||
before the signal is raised.
|
||||
|
||||
Args:
|
||||
name (str): Name of the action (used for logging and debugging).
|
||||
signal (FlowSignal): A subclass of `FlowSignal` to raise (e.g., QuitSignal).
|
||||
hooks (HookManager | None): Optional hook manager to attach lifecycle hooks.
|
||||
|
||||
Raises:
|
||||
FlowSignal: Always raises the provided signal when the action is run.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, signal: FlowSignal):
|
||||
def __init__(self, name: str, signal: FlowSignal, hooks: HookManager | None = None):
|
||||
self.signal = signal
|
||||
super().__init__(name, action=self.raise_signal)
|
||||
super().__init__(name, action=self.raise_signal, hooks=hooks)
|
||||
|
||||
async def raise_signal(self, *args, **kwargs):
|
||||
"""
|
||||
Raises the configured `FlowSignal`.
|
||||
|
||||
This method is called internally by the Falyx runtime and is the core
|
||||
behavior of the action. All hooks surrounding execution are still triggered.
|
||||
"""
|
||||
raise self.signal
|
||||
|
||||
@property
|
||||
def signal(self):
|
||||
"""Returns the configured `FlowSignal` instance."""
|
||||
return self._signal
|
||||
|
||||
@signal.setter
|
||||
def signal(self, value: FlowSignal):
|
||||
"""
|
||||
Validates that the provided value is a `FlowSignal`.
|
||||
|
||||
Raises:
|
||||
TypeError: If `value` is not an instance of `FlowSignal`.
|
||||
"""
|
||||
if not isinstance(value, FlowSignal):
|
||||
raise TypeError(
|
||||
f"Signal must be an FlowSignal instance, got {type(value).__name__}"
|
||||
|
@ -1,5 +1,31 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""user_input_action.py"""
|
||||
"""
|
||||
Defines `UserInputAction`, a Falyx Action that prompts the user for input using
|
||||
Prompt Toolkit and returns the result as a string.
|
||||
|
||||
This action is ideal for interactive CLI workflows that require user input mid-pipeline.
|
||||
It supports dynamic prompt interpolation, prompt validation, default text fallback,
|
||||
and full lifecycle hook execution.
|
||||
|
||||
Key Features:
|
||||
- Rich Prompt Toolkit integration for input and validation
|
||||
- Dynamic prompt formatting using `last_result` injection
|
||||
- Optional `Validator` support for structured input (e.g., emails, numbers)
|
||||
- Hook lifecycle compatibility (before, on_success, on_error, after, teardown)
|
||||
- Preview support for introspection or dry-run flows
|
||||
|
||||
Use Cases:
|
||||
- Asking for confirmation text or field input mid-chain
|
||||
- Injecting user-provided variables into automated pipelines
|
||||
- Interactive menu or wizard experiences
|
||||
|
||||
Example:
|
||||
UserInputAction(
|
||||
name="GetUsername",
|
||||
prompt_message="Enter your username > ",
|
||||
validator=Validator.from_callable(lambda s: len(s) > 0),
|
||||
)
|
||||
"""
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.validation import Validator
|
||||
from rich.tree import Tree
|
||||
@ -8,28 +34,36 @@ from falyx.action.base_action import BaseAction
|
||||
from falyx.context import ExecutionContext
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import HookType
|
||||
from falyx.prompt_utils import rich_text_to_prompt_text
|
||||
from falyx.signals import CancelSignal
|
||||
from falyx.themes.colors import OneColors
|
||||
|
||||
|
||||
class UserInputAction(BaseAction):
|
||||
"""
|
||||
Prompts the user for input via PromptSession and returns the result.
|
||||
Prompts the user for textual input and returns their response.
|
||||
|
||||
`UserInputAction` uses Prompt Toolkit to gather input with optional validation,
|
||||
lifecycle hook compatibility, and support for default text. If `inject_last_result`
|
||||
is enabled, the prompt message can interpolate `{last_result}` dynamically.
|
||||
|
||||
Args:
|
||||
name (str): Action name.
|
||||
prompt_text (str): Prompt text (can include '{last_result}' for interpolation).
|
||||
validator (Validator, optional): Prompt Toolkit validator.
|
||||
prompt_session (PromptSession, optional): Reusable prompt session.
|
||||
inject_last_result (bool): Whether to inject last_result into prompt.
|
||||
inject_into (str): Key to use for injection (default: 'last_result').
|
||||
name (str): Name of the action (used for introspection and logging).
|
||||
prompt_message (str): The prompt message shown to the user.
|
||||
Can include `{last_result}` if `inject_last_result=True`.
|
||||
default_text (str): Optional default value shown in the prompt.
|
||||
validator (Validator | None): Prompt Toolkit validator for input constraints.
|
||||
prompt_session (PromptSession | None): Optional custom prompt session.
|
||||
inject_last_result (bool): Whether to inject `last_result` into the prompt.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
prompt_text: str = "Input > ",
|
||||
prompt_message: str = "Input > ",
|
||||
default_text: str = "",
|
||||
multiline: bool = False,
|
||||
validator: Validator | None = None,
|
||||
prompt_session: PromptSession | None = None,
|
||||
inject_last_result: bool = False,
|
||||
@ -38,10 +72,13 @@ class UserInputAction(BaseAction):
|
||||
name=name,
|
||||
inject_last_result=inject_last_result,
|
||||
)
|
||||
self.prompt_text = prompt_text
|
||||
self.validator = validator
|
||||
self.prompt_session = prompt_session or PromptSession()
|
||||
self.prompt_message = prompt_message
|
||||
self.default_text = default_text
|
||||
self.multiline = multiline
|
||||
self.validator = validator
|
||||
self.prompt_session = prompt_session or PromptSession(
|
||||
interrupt_exception=CancelSignal
|
||||
)
|
||||
|
||||
def get_infer_target(self) -> tuple[None, None]:
|
||||
return None, None
|
||||
@ -57,14 +94,15 @@ class UserInputAction(BaseAction):
|
||||
try:
|
||||
await self.hooks.trigger(HookType.BEFORE, context)
|
||||
|
||||
prompt_text = self.prompt_text
|
||||
prompt_message = self.prompt_message
|
||||
if self.inject_last_result and self.last_result:
|
||||
prompt_text = prompt_text.format(last_result=self.last_result)
|
||||
prompt_message = prompt_message.format(last_result=self.last_result)
|
||||
|
||||
answer = await self.prompt_session.prompt_async(
|
||||
prompt_text,
|
||||
rich_text_to_prompt_text(prompt_message),
|
||||
validator=self.validator,
|
||||
default=kwargs.get("default_text", self.default_text),
|
||||
multiline=self.multiline,
|
||||
)
|
||||
context.result = answer
|
||||
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||
@ -83,12 +121,12 @@ class UserInputAction(BaseAction):
|
||||
label = f"[{OneColors.MAGENTA}]⌨ UserInputAction[/] '{self.name}'"
|
||||
tree = parent.add(label) if parent else Tree(label)
|
||||
|
||||
prompt_text = (
|
||||
self.prompt_text.replace("{last_result}", "<last_result>")
|
||||
if "{last_result}" in self.prompt_text
|
||||
else self.prompt_text
|
||||
prompt_message = (
|
||||
self.prompt_message.replace("{last_result}", "<last_result>")
|
||||
if "{last_result}" in self.prompt_message
|
||||
else self.prompt_message
|
||||
)
|
||||
tree.add(f"[dim]Prompt:[/] {prompt_text}")
|
||||
tree.add(f"[dim]Prompt:[/] {prompt_message}")
|
||||
if self.validator:
|
||||
tree.add("[dim]Validator:[/] Yes")
|
||||
if not parent:
|
||||
|
@ -1,10 +1,42 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""bottom_bar.py"""
|
||||
"""
|
||||
Provides the `BottomBar` class for managing a customizable bottom status bar in
|
||||
Falyx-based CLI applications.
|
||||
|
||||
The bottom bar is rendered using `prompt_toolkit` and supports:
|
||||
- Rich-formatted static content
|
||||
- Live-updating value trackers and counters
|
||||
- Toggle switches activated via Ctrl+<key> bindings
|
||||
- Config-driven visual and behavioral controls
|
||||
|
||||
Each item in the bar is registered by name and rendered in columns across the
|
||||
bottom of the terminal. Toggles are linked to user-defined state accessors and
|
||||
mutators, and can be automatically bound to `OptionsManager` values for full
|
||||
integration with Falyx CLI argument parsing.
|
||||
|
||||
Key Features:
|
||||
- Live rendering of structured status items using Rich-style HTML
|
||||
- Custom or built-in item types: static text, dynamic counters, toggles, value displays
|
||||
- Ctrl+key toggle handling via `prompt_toolkit.KeyBindings`
|
||||
- Columnar layout with automatic width scaling
|
||||
- Optional integration with `OptionsManager` for dynamic state toggling
|
||||
|
||||
Usage Example:
|
||||
bar = BottomBar(columns=3)
|
||||
bar.add_static("env", "ENV: dev")
|
||||
bar.add_toggle("d", "Debug", get_debug, toggle_debug)
|
||||
bar.add_value_tracker("attempts", "Retries", get_retry_count)
|
||||
bar.render()
|
||||
|
||||
Used by Falyx to provide a persistent UI element showing toggles, system state,
|
||||
and runtime telemetry below the input prompt.
|
||||
"""
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
from prompt_toolkit.formatted_text import HTML, merge_formatted_text
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
|
||||
from rich.console import Console
|
||||
|
||||
from falyx.console import console
|
||||
@ -24,11 +56,12 @@ class BottomBar:
|
||||
Must return True if key is available, otherwise False.
|
||||
"""
|
||||
|
||||
RESERVED_CTRL_KEYS = {"c", "d", "z", "v"}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
columns: int = 3,
|
||||
key_bindings: KeyBindings | None = None,
|
||||
key_validator: Callable[[str], bool] | None = None,
|
||||
) -> None:
|
||||
self.columns = columns
|
||||
self.console: Console = console
|
||||
@ -36,7 +69,6 @@ class BottomBar:
|
||||
self._value_getters: dict[str, Callable[[], Any]] = CaseInsensitiveDict()
|
||||
self.toggle_keys: list[str] = []
|
||||
self.key_bindings = key_bindings or KeyBindings()
|
||||
self.key_validator = key_validator
|
||||
|
||||
@staticmethod
|
||||
def default_render(label: str, value: Any, fg: str, bg: str, width: int) -> HTML:
|
||||
@ -121,17 +153,31 @@ class BottomBar:
|
||||
bg_on: str = OneColors.GREEN,
|
||||
bg_off: str = OneColors.DARK_RED,
|
||||
) -> None:
|
||||
"""
|
||||
Add a toggle to the bottom bar.
|
||||
Always uses the ctrl + key combination for toggling.
|
||||
|
||||
Args:
|
||||
key (str): The key to toggle the state.
|
||||
label (str): The label for the toggle.
|
||||
get_state (Callable[[], bool]): Function to get the current state.
|
||||
toggle_state (Callable[[], None]): Function to toggle the state.
|
||||
fg (str): Foreground color for the label.
|
||||
bg_on (str): Background color when the toggle is ON.
|
||||
bg_off (str): Background color when the toggle is OFF.
|
||||
"""
|
||||
key = key.lower()
|
||||
if key in self.RESERVED_CTRL_KEYS:
|
||||
raise ValueError(
|
||||
f"'{key}' is a reserved terminal control key and cannot be used for toggles."
|
||||
)
|
||||
if not callable(get_state):
|
||||
raise ValueError("`get_state` must be a callable returning bool")
|
||||
if not callable(toggle_state):
|
||||
raise ValueError("`toggle_state` must be a callable")
|
||||
key = key.upper()
|
||||
if key in self.toggle_keys:
|
||||
raise ValueError(f"Key {key} is already used as a toggle")
|
||||
if self.key_validator and not self.key_validator(key):
|
||||
raise ValueError(
|
||||
f"Key '{key}' conflicts with existing command, toggle, or reserved key."
|
||||
)
|
||||
|
||||
self._value_getters[key] = get_state
|
||||
self.toggle_keys.append(key)
|
||||
|
||||
@ -139,15 +185,13 @@ class BottomBar:
|
||||
get_state_ = self._value_getters[key]
|
||||
color = bg_on if get_state_() else bg_off
|
||||
status = "ON" if get_state_() else "OFF"
|
||||
text = f"({key.upper()}) {label}: {status}"
|
||||
text = f"(^{key.lower()}) {label}: {status}"
|
||||
return HTML(f"<style bg='{color}' fg='{fg}'>{text:^{self.space}}</style>")
|
||||
|
||||
self._add_named(key, render)
|
||||
|
||||
for k in (key.upper(), key.lower()):
|
||||
|
||||
@self.key_bindings.add(k)
|
||||
def _(_):
|
||||
@self.key_bindings.add(f"c-{key.lower()}", eager=True)
|
||||
def _(_: KeyPressEvent):
|
||||
toggle_state()
|
||||
|
||||
def add_toggle_from_option(
|
||||
|
@ -1,6 +1,5 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""command.py
|
||||
|
||||
"""
|
||||
Defines the Command class for Falyx CLI.
|
||||
|
||||
Commands are callable units representing a menu option or CLI task,
|
||||
@ -33,6 +32,7 @@ from falyx.debug import register_debug_hooks
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import HookManager, HookType
|
||||
from falyx.logger import logger
|
||||
from falyx.mode import FalyxMode
|
||||
from falyx.options_manager import OptionsManager
|
||||
from falyx.parser.command_argument_parser import CommandArgumentParser
|
||||
from falyx.parser.signature import infer_args_from_func
|
||||
@ -80,7 +80,7 @@ class Command(BaseModel):
|
||||
spinner_message (str): Spinner text message.
|
||||
spinner_type (str): Spinner style (e.g., dots, line, etc.).
|
||||
spinner_style (str): Color or style of the spinner.
|
||||
spinner_kwargs (dict): Extra spinner configuration.
|
||||
spinner_speed (float): Speed of the spinner animation.
|
||||
hooks (HookManager): Hook manager for lifecycle events.
|
||||
retry (bool): Enable retry on failure.
|
||||
retry_all (bool): Enable retry across chained or grouped actions.
|
||||
@ -92,12 +92,14 @@ class Command(BaseModel):
|
||||
arguments (list[dict[str, Any]]): Argument definitions for the command.
|
||||
argument_config (Callable[[CommandArgumentParser], None] | None): Function to configure arguments
|
||||
for the command parser.
|
||||
arg_metadata (dict[str, str | dict[str, Any]]): Metadata for arguments,
|
||||
such as help text or choices.
|
||||
simple_help_signature (bool): Whether to use a simplified help signature.
|
||||
custom_parser (ArgParserProtocol | None): Custom argument parser.
|
||||
custom_help (Callable[[], str | None] | None): Custom help message generator.
|
||||
auto_args (bool): Automatically infer arguments from the action.
|
||||
arg_metadata (dict[str, str | dict[str, Any]]): Metadata for arguments,
|
||||
such as help text or choices.
|
||||
simple_help_signature (bool): Whether to use a simplified help signature.
|
||||
ignore_in_history (bool): Whether to ignore this command in execution history last result.
|
||||
program: (str | None): The parent program name.
|
||||
|
||||
Methods:
|
||||
__call__(): Executes the command, respecting hooks and retries.
|
||||
@ -124,7 +126,7 @@ class Command(BaseModel):
|
||||
spinner_message: str = "Processing..."
|
||||
spinner_type: str = "dots"
|
||||
spinner_style: str = OneColors.CYAN
|
||||
spinner_kwargs: dict[str, Any] = Field(default_factory=dict)
|
||||
spinner_speed: float = 1.0
|
||||
hooks: "HookManager" = Field(default_factory=HookManager)
|
||||
retry: bool = False
|
||||
retry_all: bool = False
|
||||
@ -140,6 +142,8 @@ class Command(BaseModel):
|
||||
auto_args: bool = True
|
||||
arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict)
|
||||
simple_help_signature: bool = False
|
||||
ignore_in_history: bool = False
|
||||
program: str | None = None
|
||||
|
||||
_context: ExecutionContext | None = PrivateAttr(default=None)
|
||||
|
||||
@ -239,10 +243,15 @@ class Command(BaseModel):
|
||||
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 self.ignore_in_history and isinstance(self.action, BaseAction):
|
||||
self.action.ignore_in_history = True
|
||||
|
||||
def _inject_options_manager(self) -> None:
|
||||
"""Inject the options manager into the action if applicable."""
|
||||
if isinstance(self.action, BaseAction):
|
||||
@ -275,15 +284,6 @@ class Command(BaseModel):
|
||||
|
||||
try:
|
||||
await self.hooks.trigger(HookType.BEFORE, context)
|
||||
if self.spinner:
|
||||
with console.status(
|
||||
self.spinner_message,
|
||||
spinner=self.spinner_type,
|
||||
spinner_style=self.spinner_style,
|
||||
**self.spinner_kwargs,
|
||||
):
|
||||
result = await self.action(*combined_args, **combined_kwargs)
|
||||
else:
|
||||
result = await self.action(*combined_args, **combined_kwargs)
|
||||
|
||||
context.result = result
|
||||
@ -340,26 +340,40 @@ class Command(BaseModel):
|
||||
return f" {command_keys_text:<20} {options_text} "
|
||||
|
||||
@property
|
||||
def help_signature(self) -> str:
|
||||
def help_signature(self) -> tuple[str, str, str]:
|
||||
"""Generate a help signature for the command."""
|
||||
is_cli_mode = self.options_manager.get("mode") in {
|
||||
FalyxMode.RUN,
|
||||
FalyxMode.PREVIEW,
|
||||
FalyxMode.RUN_ALL,
|
||||
}
|
||||
|
||||
program = f"{self.program} run " if is_cli_mode else ""
|
||||
|
||||
if self.arg_parser and not self.simple_help_signature:
|
||||
signature = [self.arg_parser.get_usage()]
|
||||
signature.append(f" {self.help_text or self.description}")
|
||||
usage = f"[{self.style}]{program}[/]{self.arg_parser.get_usage()}"
|
||||
description = f"[dim]{self.help_text or self.description}[/dim]"
|
||||
if self.tags:
|
||||
signature.append(f" [dim]Tags: {', '.join(self.tags)}[/dim]")
|
||||
return "\n".join(signature).strip()
|
||||
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} {self.description}"
|
||||
return (
|
||||
f"[{self.style}]{program}[/]{command_keys}",
|
||||
f"[dim]{self.description}[/dim]",
|
||||
"",
|
||||
)
|
||||
|
||||
def log_summary(self) -> None:
|
||||
if self._context:
|
||||
self._context.log_summary()
|
||||
|
||||
def show_help(self) -> bool:
|
||||
def render_help(self) -> bool:
|
||||
"""Display the help message for the command."""
|
||||
if callable(self.custom_help):
|
||||
output = self.custom_help()
|
||||
|
@ -1,3 +1,20 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""
|
||||
Provides `FalyxCompleter`, an intelligent autocompletion engine for Falyx CLI
|
||||
menus using Prompt Toolkit.
|
||||
|
||||
This completer supports:
|
||||
- Command key and alias completion (e.g. `R`, `HELP`, `X`)
|
||||
- Argument flag completion for registered commands (e.g. `--tag`, `--name`)
|
||||
- Context-aware suggestions based on cursor position and argument structure
|
||||
- Interactive value completions (e.g. choices and suggestions defined per argument)
|
||||
|
||||
Completions are sourced from `CommandArgumentParser.suggest_next`, which analyzes
|
||||
parsed tokens to determine appropriate next arguments, flags, or values.
|
||||
|
||||
Integrated with the `Falyx.prompt_session` to enhance the interactive experience.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
@ -11,12 +28,38 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class FalyxCompleter(Completer):
|
||||
"""Completer for Falyx commands."""
|
||||
"""
|
||||
Prompt Toolkit completer for Falyx CLI command input.
|
||||
|
||||
This completer provides real-time, context-aware suggestions for:
|
||||
- Command keys and aliases (resolved via Falyx._name_map)
|
||||
- CLI argument flags and values for each command
|
||||
- Suggestions and choices defined in the associated CommandArgumentParser
|
||||
|
||||
It leverages `CommandArgumentParser.suggest_next()` to compute valid completions
|
||||
based on current argument state, including:
|
||||
- Remaining required or optional flags
|
||||
- Flag value suggestions (choices or custom completions)
|
||||
- Next positional argument hints
|
||||
|
||||
Args:
|
||||
falyx (Falyx): The Falyx menu instance containing all command mappings and parsers.
|
||||
"""
|
||||
|
||||
def __init__(self, falyx: "Falyx"):
|
||||
self.falyx = falyx
|
||||
|
||||
def get_completions(self, document: Document, complete_event) -> Iterable[Completion]:
|
||||
"""
|
||||
Yield completions based on the current document input.
|
||||
|
||||
Args:
|
||||
document (Document): The prompt_toolkit document containing the input buffer.
|
||||
complete_event: The completion trigger event (unused).
|
||||
|
||||
Yields:
|
||||
Completion objects matching command keys or argument suggestions.
|
||||
"""
|
||||
text = document.text_before_cursor
|
||||
try:
|
||||
tokens = shlex.split(text)
|
||||
@ -29,7 +72,45 @@ class FalyxCompleter(Completer):
|
||||
yield from self._suggest_commands(tokens[0] if tokens else "")
|
||||
return
|
||||
|
||||
# Identify command
|
||||
command_key = tokens[0].upper()
|
||||
command = self.falyx._name_map.get(command_key)
|
||||
if not command or not command.arg_parser:
|
||||
return
|
||||
|
||||
# If at end of token, e.g., "--t" vs "--tag ", add a stub so suggest_next sees it
|
||||
parsed_args = tokens[1:] if cursor_at_end_of_token else tokens[1:-1]
|
||||
stub = "" if cursor_at_end_of_token else tokens[-1]
|
||||
|
||||
try:
|
||||
if not command.arg_parser:
|
||||
return
|
||||
suggestions = command.arg_parser.suggest_next(
|
||||
parsed_args + ([stub] if stub else []), cursor_at_end_of_token
|
||||
)
|
||||
for suggestion in suggestions:
|
||||
if suggestion.startswith(stub):
|
||||
if len(suggestion.split()) > 1:
|
||||
yield Completion(
|
||||
f'"{suggestion}"',
|
||||
start_position=-len(stub),
|
||||
display=suggestion,
|
||||
)
|
||||
else:
|
||||
yield Completion(suggestion, start_position=-len(stub))
|
||||
except Exception:
|
||||
return
|
||||
|
||||
def _suggest_commands(self, prefix: str) -> Iterable[Completion]:
|
||||
"""
|
||||
Suggest top-level command keys and aliases based on the given prefix.
|
||||
|
||||
Args:
|
||||
prefix (str): The user input to match against available commands.
|
||||
|
||||
Yields:
|
||||
Completion: Matching keys or aliases from all registered commands.
|
||||
"""
|
||||
prefix = prefix.upper()
|
||||
keys = [self.falyx.exit_command.key]
|
||||
keys.extend(self.falyx.exit_command.aliases)
|
||||
|
@ -1,6 +1,41 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""config.py
|
||||
Configuration loader for Falyx CLI commands."""
|
||||
"""
|
||||
Configuration loader and schema definitions for the Falyx CLI framework.
|
||||
|
||||
This module supports config-driven initialization of CLI commands and submenus
|
||||
from YAML or TOML files. It enables declarative command definitions, auto-imports
|
||||
Python callables from dotted paths, and wraps them in `Action` or `Command` objects
|
||||
as needed.
|
||||
|
||||
Features:
|
||||
- Parses Falyx command and submenu definitions from YAML or TOML.
|
||||
- Supports hooks, retry policies, confirm prompts, spinners, aliases, and tags.
|
||||
- Dynamically imports Python functions/classes from `action:` strings.
|
||||
- Wraps user callables into Falyx `Command` or `Action` instances.
|
||||
- Validates prompt and retry configuration using `pydantic` models.
|
||||
|
||||
Main Components:
|
||||
- `FalyxConfig`: Pydantic model for top-level config structure.
|
||||
- `RawCommand`: Intermediate command definition model from raw config.
|
||||
- `Submenu`: Schema for nested CLI menus.
|
||||
- `loader(path)`: Loads and returns a fully constructed `Falyx` instance.
|
||||
|
||||
Typical Config (YAML):
|
||||
```yaml
|
||||
title: My CLI
|
||||
commands:
|
||||
- key: A
|
||||
description: Say hello
|
||||
action: my_package.tasks.hello
|
||||
aliases: [hi]
|
||||
tags: [example]
|
||||
```
|
||||
|
||||
Example:
|
||||
from falyx.config import loader
|
||||
cli = loader("falyx.yaml")
|
||||
cli.run()
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
@ -85,7 +120,7 @@ class RawCommand(BaseModel):
|
||||
spinner_message: str = "Processing..."
|
||||
spinner_type: str = "dots"
|
||||
spinner_style: str = OneColors.CYAN
|
||||
spinner_kwargs: dict[str, Any] = Field(default_factory=dict)
|
||||
spinner_speed: float = 1.0
|
||||
|
||||
before_hooks: list[Callable] = Field(default_factory=list)
|
||||
success_hooks: list[Callable] = Field(default_factory=list)
|
||||
|
@ -1,3 +1,5 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""Global console instance for Falyx CLI applications."""
|
||||
from rich.console import Console
|
||||
|
||||
from falyx.themes import get_nord_theme
|
||||
|
@ -19,6 +19,7 @@ from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
from traceback import format_exception
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
@ -75,7 +76,8 @@ class ExecutionContext(BaseModel):
|
||||
kwargs: dict = Field(default_factory=dict)
|
||||
action: Any
|
||||
result: Any | None = None
|
||||
exception: BaseException | None = None
|
||||
traceback: str | None = None
|
||||
_exception: BaseException | None = None
|
||||
|
||||
start_time: float | None = None
|
||||
end_time: float | None = None
|
||||
@ -122,6 +124,16 @@ class ExecutionContext(BaseModel):
|
||||
def status(self) -> str:
|
||||
return "OK" if self.success else "ERROR"
|
||||
|
||||
@property
|
||||
def exception(self) -> BaseException | None:
|
||||
return self._exception
|
||||
|
||||
@exception.setter
|
||||
def exception(self, exc: BaseException | None):
|
||||
self._exception = exc
|
||||
if exc is not None:
|
||||
self.traceback = "".join(format_exception(exc)).strip()
|
||||
|
||||
@property
|
||||
def signature(self) -> str:
|
||||
"""
|
||||
@ -138,6 +150,7 @@ class ExecutionContext(BaseModel):
|
||||
"name": self.name,
|
||||
"result": self.result,
|
||||
"exception": repr(self.exception) if self.exception else None,
|
||||
"traceback": self.traceback,
|
||||
"duration": self.duration,
|
||||
"extra": self.extra,
|
||||
}
|
||||
|
@ -1,5 +1,18 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""debug.py"""
|
||||
"""
|
||||
Provides debug logging hooks for Falyx action execution.
|
||||
|
||||
This module defines lifecycle hook functions (`log_before`, `log_success`, `log_after`, `log_error`)
|
||||
that can be registered with a `HookManager` to trace command execution.
|
||||
|
||||
Logs include:
|
||||
- Action invocation with argument signature
|
||||
- Success result (with truncation for large outputs)
|
||||
- Errors with full exception info
|
||||
- Total runtime duration after execution
|
||||
|
||||
Also exports `register_debug_hooks()` to register all log hooks in bulk.
|
||||
"""
|
||||
from falyx.context import ExecutionContext
|
||||
from falyx.hook_manager import HookManager, HookType
|
||||
from falyx.logger import logger
|
||||
|
@ -1,5 +1,28 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""exceptions.py"""
|
||||
"""
|
||||
Defines all custom exception classes used in the Falyx CLI framework.
|
||||
|
||||
These exceptions provide structured error handling for common failure cases,
|
||||
including command conflicts, invalid actions or hooks, parser errors, and execution guards
|
||||
like circuit breakers or empty workflows.
|
||||
|
||||
All exceptions inherit from `FalyxError`, the base exception for the framework.
|
||||
|
||||
Exception Hierarchy:
|
||||
- FalyxError
|
||||
├── CommandAlreadyExistsError
|
||||
├── InvalidHookError
|
||||
├── InvalidActionError
|
||||
├── NotAFalyxError
|
||||
├── CircuitBreakerOpen
|
||||
├── EmptyChainError
|
||||
├── EmptyGroupError
|
||||
├── EmptyPoolError
|
||||
└── CommandArgumentError
|
||||
|
||||
These are raised internally throughout the Falyx system to signal user-facing or
|
||||
developer-facing problems that should be caught and reported.
|
||||
"""
|
||||
|
||||
|
||||
class FalyxError(Exception):
|
||||
|
@ -1,29 +1,49 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""
|
||||
execution_registry.py
|
||||
Provides the `ExecutionRegistry`, a centralized runtime store for capturing and inspecting
|
||||
the execution history of Falyx actions.
|
||||
|
||||
This module provides the `ExecutionRegistry`, a global class for tracking and
|
||||
introspecting the execution history of Falyx actions.
|
||||
The registry automatically records every `ExecutionContext` created during action
|
||||
execution—including context metadata, results, exceptions, duration, and tracebacks.
|
||||
It supports filtering, summarization, and visual inspection via a Rich-rendered table.
|
||||
|
||||
The registry captures `ExecutionContext` instances from all executed actions, making it
|
||||
easy to debug, audit, and visualize workflow behavior over time. It supports retrieval,
|
||||
filtering, clearing, and formatted summary display.
|
||||
Designed for:
|
||||
- Workflow debugging and CLI diagnostics
|
||||
- Interactive history browsing or replaying previous runs
|
||||
- Providing user-visible `history` or `last-result` commands inside CLI apps
|
||||
|
||||
Core Features:
|
||||
- Stores all action execution contexts globally (with access by name).
|
||||
- Provides live execution summaries in a rich table format.
|
||||
- Enables creation of a built-in Falyx Action to print history on demand.
|
||||
- Integrates with Falyx's introspectable and hook-driven execution model.
|
||||
|
||||
Intended for:
|
||||
- Debugging and diagnostics
|
||||
- Post-run inspection of CLI workflows
|
||||
- Interactive tools built with Falyx
|
||||
Key Features:
|
||||
- Global, in-memory store of all `ExecutionContext` objects (by name, index, or full list)
|
||||
- Thread-safe indexing and summary display
|
||||
- Traceback-aware result inspection and filtering by status (success/error)
|
||||
- Used by built-in `History` command in Falyx CLI
|
||||
|
||||
Example:
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
|
||||
# Record a context
|
||||
er.record(context)
|
||||
|
||||
# Display a rich table summary
|
||||
er.summary()
|
||||
|
||||
# Print the last non-ignored result
|
||||
er.summary(last_result=True)
|
||||
|
||||
# Clear execution history
|
||||
er.summary(clear=True)
|
||||
|
||||
Note:
|
||||
The registry is volatile and cleared on each process restart or when `clear()` is called.
|
||||
All data is retained in memory only.
|
||||
|
||||
Public Interface:
|
||||
- record(context): Log an ExecutionContext and assign index.
|
||||
- get_all(): List all stored contexts.
|
||||
- get_by_name(name): Retrieve all contexts by action name.
|
||||
- get_latest(): Retrieve the most recent context.
|
||||
- clear(): Reset the registry.
|
||||
- summary(...): Rich console summary of stored execution results.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@ -46,42 +66,45 @@ class ExecutionRegistry:
|
||||
"""
|
||||
Global registry for recording and inspecting Falyx action executions.
|
||||
|
||||
This class captures every `ExecutionContext` generated by a Falyx `Action`,
|
||||
`ChainedAction`, or `ActionGroup`, maintaining both full history and
|
||||
name-indexed access for filtered analysis.
|
||||
This class captures every `ExecutionContext` created by Falyx Actions,
|
||||
tracking metadata, results, exceptions, and performance metrics. It enables
|
||||
rich introspection, post-execution inspection, and formatted summaries
|
||||
suitable for interactive and headless CLI use.
|
||||
|
||||
Methods:
|
||||
- record(context): Stores an ExecutionContext, logging a summary line.
|
||||
- get_all(): Returns the list of all recorded executions.
|
||||
- get_by_name(name): Returns all executions with the given action name.
|
||||
- get_latest(): Returns the most recent execution.
|
||||
- clear(): Wipes the registry for a fresh run.
|
||||
- summary(): Renders a formatted Rich table of all execution results.
|
||||
Data is retained in memory until cleared or process exit.
|
||||
|
||||
Use Cases:
|
||||
- Debugging chained or factory-generated workflows
|
||||
- Viewing results and exceptions from multiple runs
|
||||
- Embedding a diagnostic command into your CLI for user support
|
||||
- Auditing chained or dynamic workflows
|
||||
- Rendering execution history in a help/debug menu
|
||||
- Accessing previous results or errors for reuse
|
||||
|
||||
Note:
|
||||
This registry is in-memory and not persistent. It's reset each time the process
|
||||
restarts or `clear()` is called.
|
||||
|
||||
Example:
|
||||
ExecutionRegistry.record(context)
|
||||
ExecutionRegistry.summary()
|
||||
Attributes:
|
||||
_store_by_name (dict): Maps action name → list of ExecutionContext objects.
|
||||
_store_by_index (dict): Maps numeric index → ExecutionContext.
|
||||
_store_all (list): Ordered list of all contexts.
|
||||
_index (int): Global counter for assigning unique execution indices.
|
||||
_lock (Lock): Thread lock for atomic writes to the registry.
|
||||
_console (Console): Rich console used for rendering summaries.
|
||||
"""
|
||||
|
||||
_store_by_name: dict[str, list[ExecutionContext]] = defaultdict(list)
|
||||
_store_by_index: dict[int, ExecutionContext] = {}
|
||||
_store_all: list[ExecutionContext] = []
|
||||
_console = Console(color_system="truecolor")
|
||||
_console: Console = console
|
||||
_index = 0
|
||||
_lock = Lock()
|
||||
|
||||
@classmethod
|
||||
def record(cls, context: ExecutionContext):
|
||||
"""Record an execution context."""
|
||||
"""
|
||||
Record an execution context and assign a unique index.
|
||||
|
||||
This method logs the context, appends it to the registry,
|
||||
and makes it available for future summary or filtering.
|
||||
|
||||
Args:
|
||||
context (ExecutionContext): The context to be tracked.
|
||||
"""
|
||||
logger.debug(context.to_log_line())
|
||||
with cls._lock:
|
||||
context.index = cls._index
|
||||
@ -92,18 +115,44 @@ class ExecutionRegistry:
|
||||
|
||||
@classmethod
|
||||
def get_all(cls) -> list[ExecutionContext]:
|
||||
"""
|
||||
Return all recorded execution contexts in order of execution.
|
||||
|
||||
Returns:
|
||||
list[ExecutionContext]: All stored action contexts.
|
||||
"""
|
||||
return cls._store_all
|
||||
|
||||
@classmethod
|
||||
def get_by_name(cls, name: str) -> list[ExecutionContext]:
|
||||
"""
|
||||
Retrieve all executions recorded under a given action name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the action.
|
||||
|
||||
Returns:
|
||||
list[ExecutionContext]: Matching contexts, or empty if none found.
|
||||
"""
|
||||
return cls._store_by_name.get(name, [])
|
||||
|
||||
@classmethod
|
||||
def get_latest(cls) -> ExecutionContext:
|
||||
"""
|
||||
Return the most recent execution context.
|
||||
|
||||
Returns:
|
||||
ExecutionContext: The last recorded context.
|
||||
"""
|
||||
return cls._store_all[-1]
|
||||
|
||||
@classmethod
|
||||
def clear(cls):
|
||||
"""
|
||||
Clear all stored execution data and reset internal indices.
|
||||
|
||||
This operation is destructive and cannot be undone.
|
||||
"""
|
||||
cls._store_by_name.clear()
|
||||
cls._store_all.clear()
|
||||
cls._store_by_index.clear()
|
||||
@ -118,6 +167,21 @@ class ExecutionRegistry:
|
||||
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.")
|
||||
@ -125,13 +189,11 @@ class ExecutionRegistry:
|
||||
|
||||
if last_result:
|
||||
for ctx in reversed(cls._store_all):
|
||||
if ctx.name.upper() not in [
|
||||
"HISTORY",
|
||||
"HELP",
|
||||
"EXIT",
|
||||
"VIEW EXECUTION HISTORY",
|
||||
"BACK",
|
||||
]:
|
||||
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(
|
||||
@ -148,8 +210,8 @@ class ExecutionRegistry:
|
||||
)
|
||||
return
|
||||
cls._console.print(f"{result_context.signature}:")
|
||||
if result_context.exception:
|
||||
cls._console.print(result_context.exception)
|
||||
if result_context.traceback:
|
||||
cls._console.print(result_context.traceback)
|
||||
else:
|
||||
cls._console.print(result_context.result)
|
||||
return
|
||||
@ -205,8 +267,8 @@ class ExecutionRegistry:
|
||||
elif status.lower() in ["all", "success"]:
|
||||
final_status = f"[{OneColors.GREEN}]✅ Success"
|
||||
final_result = repr(ctx.result)
|
||||
if len(final_result) > 1000:
|
||||
final_result = f"{final_result[:1000]}..."
|
||||
if len(final_result) > 50:
|
||||
final_result = f"{final_result[:50]}..."
|
||||
else:
|
||||
continue
|
||||
|
||||
|
344
falyx/falyx.py
344
falyx/falyx.py
@ -1,5 +1,6 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""Main class for constructing and running Falyx CLI menus.
|
||||
"""
|
||||
Main class for constructing and running Falyx CLI menus.
|
||||
|
||||
Falyx provides a structured, customizable interactive menu system
|
||||
for running commands, actions, and workflows. It supports:
|
||||
@ -25,18 +26,23 @@ import shlex
|
||||
import sys
|
||||
from argparse import ArgumentParser, Namespace, _SubParsersAction
|
||||
from difflib import get_close_matches
|
||||
from enum import Enum
|
||||
from functools import cached_property
|
||||
from pathlib import Path
|
||||
from random import choice
|
||||
from typing import Any, Callable
|
||||
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.formatted_text import AnyFormattedText
|
||||
from prompt_toolkit.application import get_app
|
||||
from prompt_toolkit.formatted_text import StyleAndTextTuples
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
from prompt_toolkit.patch_stdout import patch_stdout
|
||||
from prompt_toolkit.validation import ValidationError, Validator
|
||||
from prompt_toolkit.validation import ValidationError
|
||||
from rich import box
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
from rich.padding import Padding
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from falyx.action.action import Action
|
||||
@ -56,56 +62,21 @@ from falyx.exceptions import (
|
||||
)
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
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.mode import FalyxMode
|
||||
from falyx.options_manager import OptionsManager
|
||||
from falyx.parser import CommandArgumentParser, FalyxParsers, get_arg_parsers
|
||||
from falyx.prompt_utils import rich_text_to_prompt_text
|
||||
from falyx.protocols import ArgParserProtocol
|
||||
from falyx.retry import RetryPolicy
|
||||
from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
|
||||
from falyx.themes import OneColors
|
||||
from falyx.utils import CaseInsensitiveDict, _noop, chunks
|
||||
from falyx.utils import CaseInsensitiveDict, _noop, chunks, ensure_async
|
||||
from falyx.validators import CommandValidator
|
||||
from falyx.version import __version__
|
||||
|
||||
|
||||
class FalyxMode(Enum):
|
||||
MENU = "menu"
|
||||
RUN = "run"
|
||||
PREVIEW = "preview"
|
||||
RUN_ALL = "run-all"
|
||||
|
||||
|
||||
class CommandValidator(Validator):
|
||||
"""Validator to check if the input is a valid command or toggle key."""
|
||||
|
||||
def __init__(self, falyx: Falyx, error_message: str) -> None:
|
||||
super().__init__()
|
||||
self.falyx = falyx
|
||||
self.error_message = error_message
|
||||
|
||||
def validate(self, document) -> None:
|
||||
if not document.text:
|
||||
raise ValidationError(
|
||||
message=self.error_message,
|
||||
cursor_position=len(document.text),
|
||||
)
|
||||
|
||||
async def validate_async(self, document) -> None:
|
||||
text = document.text
|
||||
if not text:
|
||||
raise ValidationError(
|
||||
message=self.error_message,
|
||||
cursor_position=len(text),
|
||||
)
|
||||
is_preview, choice, _, __ = await self.falyx.get_command(text, from_validate=True)
|
||||
if is_preview:
|
||||
return None
|
||||
if not choice:
|
||||
raise ValidationError(
|
||||
message=self.error_message,
|
||||
cursor_position=len(text),
|
||||
)
|
||||
|
||||
|
||||
class Falyx:
|
||||
"""
|
||||
Main menu controller for Falyx CLI applications.
|
||||
@ -166,7 +137,7 @@ class Falyx:
|
||||
epilog: str | None = None,
|
||||
version: str = __version__,
|
||||
version_style: str = OneColors.BLUE_b,
|
||||
prompt: str | AnyFormattedText = "> ",
|
||||
prompt: str | StyleAndTextTuples = "> ",
|
||||
columns: int = 3,
|
||||
bottom_bar: BottomBar | str | Callable[[], Any] | None = None,
|
||||
welcome_message: str | Markdown | dict[str, Any] = "",
|
||||
@ -181,25 +152,21 @@ class Falyx:
|
||||
render_menu: Callable[[Falyx], None] | None = None,
|
||||
custom_table: Callable[[Falyx], Table] | Table | None = None,
|
||||
hide_menu_table: bool = False,
|
||||
show_placeholder_menu: bool = False,
|
||||
prompt_history_base_dir: Path = Path.home(),
|
||||
enable_prompt_history: bool = False,
|
||||
) -> None:
|
||||
"""Initializes the Falyx object."""
|
||||
self.title: str | Markdown = title
|
||||
self.program: str | None = program
|
||||
self.program: str = program or ""
|
||||
self.usage: str | None = usage
|
||||
self.description: str | None = description
|
||||
self.epilog: str | None = epilog
|
||||
self.version: str = version
|
||||
self.version_style: str = version_style
|
||||
self.prompt: str | AnyFormattedText = prompt
|
||||
self.prompt: str | StyleAndTextTuples = rich_text_to_prompt_text(prompt)
|
||||
self.columns: int = columns
|
||||
self.commands: dict[str, Command] = CaseInsensitiveDict()
|
||||
self.exit_command: Command = self._get_exit_command()
|
||||
self.history_command: Command | None = (
|
||||
self._get_history_command() if include_history_command else None
|
||||
)
|
||||
self.help_command: Command | None = (
|
||||
self._get_help_command() if include_help_command else None
|
||||
)
|
||||
self.console: Console = console
|
||||
self.welcome_message: str | Markdown | dict[str, Any] = welcome_message
|
||||
self.exit_message: str | Markdown | dict[str, Any] = exit_message
|
||||
@ -213,9 +180,33 @@ class Falyx:
|
||||
self.render_menu: Callable[[Falyx], None] | None = render_menu
|
||||
self.custom_table: Callable[[Falyx], Table] | Table | None = custom_table
|
||||
self._hide_menu_table: bool = hide_menu_table
|
||||
self.show_placeholder_menu: bool = show_placeholder_menu
|
||||
self.validate_options(cli_args, options)
|
||||
self._prompt_session: PromptSession | None = None
|
||||
self.mode = FalyxMode.MENU
|
||||
self.options.set("mode", FalyxMode.MENU)
|
||||
self.exit_command: Command = self._get_exit_command()
|
||||
self.history_command: Command | None = (
|
||||
self._get_history_command() if include_history_command else None
|
||||
)
|
||||
self.help_command: Command | None = (
|
||||
self._get_help_command() if include_help_command else None
|
||||
)
|
||||
if enable_prompt_history:
|
||||
program = (self.program or "falyx").split(".")[0].replace(" ", "_")
|
||||
self.history_path: Path = (
|
||||
Path(prompt_history_base_dir) / f".{program}_history"
|
||||
)
|
||||
self.history: FileHistory | None = FileHistory(self.history_path)
|
||||
else:
|
||||
self.history = None
|
||||
|
||||
@property
|
||||
def is_cli_mode(self) -> bool:
|
||||
return self.options.get("mode") in {
|
||||
FalyxMode.RUN,
|
||||
FalyxMode.PREVIEW,
|
||||
FalyxMode.RUN_ALL,
|
||||
}
|
||||
|
||||
def validate_options(
|
||||
self,
|
||||
@ -295,6 +286,9 @@ class Falyx:
|
||||
aliases=["EXIT", "QUIT"],
|
||||
style=OneColors.DARK_RED,
|
||||
simple_help_signature=True,
|
||||
ignore_in_history=True,
|
||||
options_manager=self.options,
|
||||
program=self.program,
|
||||
)
|
||||
|
||||
def _get_history_command(self) -> Command:
|
||||
@ -304,6 +298,7 @@ class Falyx:
|
||||
command_description="History",
|
||||
command_style=OneColors.DARK_YELLOW,
|
||||
aliases=["HISTORY"],
|
||||
program=self.program,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-n",
|
||||
@ -331,9 +326,8 @@ class Falyx:
|
||||
)
|
||||
parser.add_argument(
|
||||
"-r",
|
||||
"--result",
|
||||
"--result-index",
|
||||
type=int,
|
||||
dest="result_index",
|
||||
help="Get the result by index",
|
||||
)
|
||||
parser.add_argument(
|
||||
@ -347,45 +341,105 @@ class Falyx:
|
||||
style=OneColors.DARK_YELLOW,
|
||||
arg_parser=parser,
|
||||
help_text="View the execution history of commands.",
|
||||
ignore_in_history=True,
|
||||
options_manager=self.options,
|
||||
program=self.program,
|
||||
)
|
||||
|
||||
async def _show_help(self, tag: str = "") -> None:
|
||||
if tag:
|
||||
table = Table(
|
||||
title=tag.upper(),
|
||||
title_justify="left",
|
||||
show_header=False,
|
||||
box=box.SIMPLE,
|
||||
show_footer=False,
|
||||
def get_tip(self) -> str:
|
||||
program = f"{self.program} run " if self.is_cli_mode else ""
|
||||
tips = [
|
||||
f"Use '{program}?[COMMAND]' to preview a command.",
|
||||
"Every command supports aliases—try abbreviating the name!",
|
||||
f"Use '{program}H' to reopen this help menu anytime.",
|
||||
f"'{program}[COMMAND] --help' prints a detailed help message.",
|
||||
"[bold]CLI[/] and [bold]Menu[/] mode—commands run the same way in both.",
|
||||
f"'{self.program} --never-prompt' to disable all prompts for the [bold italic]entire menu session[/].",
|
||||
f"Use '{self.program} --verbose' to enable debug logging for a menu session.",
|
||||
f"'{self.program} --debug-hooks' will trace every before/after hook in action.",
|
||||
f"Run commands directly from the CLI: '{self.program} run [COMMAND] [OPTIONS]'.",
|
||||
]
|
||||
if self.is_cli_mode:
|
||||
tips.extend(
|
||||
[
|
||||
f"Use '{self.program} run ?' to list all commands at any time.",
|
||||
f"Use '{self.program} --never-prompt run [COMMAND] [OPTIONS]' to disable all prompts for [bold italic]just this command[/].",
|
||||
f"Use '{self.program} run --skip-confirm [COMMAND] [OPTIONS]' to skip confirmations.",
|
||||
f"Use '{self.program} run --summary [COMMAND] [OPTIONS]' to print a post-run summary.",
|
||||
f"Use '{self.program} --verbose run [COMMAND] [OPTIONS]' to enable debug logging for any run.",
|
||||
"Use '--skip-confirm' for automation scripts where no prompts are wanted.",
|
||||
]
|
||||
)
|
||||
else:
|
||||
tips.extend(
|
||||
[
|
||||
"Use '[?]' alone to list all commands at any time.",
|
||||
"'[CTRL+KEY]' toggles are available in menu mode for quick switches.",
|
||||
"'[Y]' opens the command history viewer.",
|
||||
"Use '[X]' in menu mode to exit.",
|
||||
]
|
||||
)
|
||||
return choice(tips)
|
||||
|
||||
async def _render_help(self, tag: str = "") -> None:
|
||||
if tag:
|
||||
tag_lower = tag.lower()
|
||||
self.console.print(f"[bold]{tag_lower}:[/bold]")
|
||||
commands = [
|
||||
command
|
||||
for command in self.commands.values()
|
||||
if any(tag_lower == tag.lower() for tag in command.tags)
|
||||
]
|
||||
for command in commands:
|
||||
table.add_row(command.help_signature)
|
||||
self.console.print(table)
|
||||
if not commands:
|
||||
self.console.print(f"'{tag}'... Nothing to show here")
|
||||
return
|
||||
else:
|
||||
table = Table(
|
||||
title="Help",
|
||||
title_justify="left",
|
||||
title_style=OneColors.LIGHT_YELLOW_b,
|
||||
show_header=False,
|
||||
show_footer=False,
|
||||
box=box.SIMPLE,
|
||||
)
|
||||
for command in commands:
|
||||
usage, description, _ = command.help_signature
|
||||
self.console.print(usage)
|
||||
if description:
|
||||
self.console.print(description)
|
||||
return
|
||||
|
||||
self.console.print("[bold]help:[/bold]")
|
||||
for command in self.commands.values():
|
||||
table.add_row(command.help_signature)
|
||||
usage, description, tag = command.help_signature
|
||||
self.console.print(
|
||||
Padding(
|
||||
Panel(
|
||||
usage,
|
||||
expand=False,
|
||||
title=description,
|
||||
title_align="left",
|
||||
subtitle=tag,
|
||||
),
|
||||
(0, 2),
|
||||
)
|
||||
)
|
||||
if self.help_command:
|
||||
table.add_row(self.help_command.help_signature)
|
||||
usage, description, _ = self.help_command.help_signature
|
||||
self.console.print(
|
||||
Padding(
|
||||
Panel(usage, expand=False, title=description, title_align="left"),
|
||||
(0, 2),
|
||||
)
|
||||
)
|
||||
if not self.is_cli_mode:
|
||||
if self.history_command:
|
||||
table.add_row(self.history_command.help_signature)
|
||||
table.add_row(self.exit_command.help_signature)
|
||||
table.add_row(f"Tip: '[{OneColors.LIGHT_YELLOW}]?[KEY][/]' to preview a command ")
|
||||
self.console.print(table)
|
||||
usage, description, _ = self.history_command.help_signature
|
||||
self.console.print(
|
||||
Padding(
|
||||
Panel(usage, expand=False, title=description, title_align="left"),
|
||||
(0, 2),
|
||||
)
|
||||
)
|
||||
usage, description, _ = self.exit_command.help_signature
|
||||
self.console.print(
|
||||
Padding(
|
||||
Panel(usage, expand=False, title=description, title_align="left"),
|
||||
(0, 2),
|
||||
)
|
||||
)
|
||||
self.console.print(f"[bold]tip:[/bold] {self.get_tip()}")
|
||||
|
||||
def _get_help_command(self) -> Command:
|
||||
"""Returns the help command for the menu."""
|
||||
@ -394,6 +448,7 @@ class Falyx:
|
||||
command_description="Help",
|
||||
command_style=OneColors.LIGHT_YELLOW,
|
||||
aliases=["?", "HELP", "LIST"],
|
||||
program=self.program,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-t",
|
||||
@ -407,9 +462,12 @@ class Falyx:
|
||||
aliases=["?", "HELP", "LIST"],
|
||||
description="Help",
|
||||
help_text="Show this help menu",
|
||||
action=Action("Help", self._show_help),
|
||||
action=Action("Help", self._render_help),
|
||||
style=OneColors.LIGHT_YELLOW,
|
||||
arg_parser=parser,
|
||||
ignore_in_history=True,
|
||||
options_manager=self.options,
|
||||
program=self.program,
|
||||
)
|
||||
|
||||
def _get_completer(self) -> FalyxCompleter:
|
||||
@ -417,7 +475,7 @@ class Falyx:
|
||||
return FalyxCompleter(self)
|
||||
|
||||
def _get_validator_error_message(self) -> str:
|
||||
"""Validator to check if the input is a valid command or toggle key."""
|
||||
"""Validator to check if the input is a valid command."""
|
||||
keys = {self.exit_command.key.upper()}
|
||||
keys.update({alias.upper() for alias in self.exit_command.aliases})
|
||||
if self.history_command:
|
||||
@ -431,19 +489,12 @@ class Falyx:
|
||||
keys.add(cmd.key.upper())
|
||||
keys.update({alias.upper() for alias in cmd.aliases})
|
||||
|
||||
if isinstance(self._bottom_bar, BottomBar):
|
||||
toggle_keys = {key.upper() for key in self._bottom_bar.toggle_keys}
|
||||
else:
|
||||
toggle_keys = set()
|
||||
|
||||
commands_str = ", ".join(sorted(keys))
|
||||
toggles_str = ", ".join(sorted(toggle_keys))
|
||||
|
||||
message_lines = ["Invalid input. Available keys:"]
|
||||
if keys:
|
||||
message_lines.append(f" Commands: {commands_str}")
|
||||
if toggle_keys:
|
||||
message_lines.append(f" Toggles: {toggles_str}")
|
||||
|
||||
error_message = " ".join(message_lines)
|
||||
return error_message
|
||||
|
||||
@ -473,10 +524,9 @@ class Falyx:
|
||||
"""Sets the bottom bar for the menu."""
|
||||
if bottom_bar is None:
|
||||
self._bottom_bar: BottomBar | str | Callable[[], Any] = BottomBar(
|
||||
self.columns, self.key_bindings, key_validator=self.is_key_available
|
||||
self.columns, self.key_bindings
|
||||
)
|
||||
elif isinstance(bottom_bar, BottomBar):
|
||||
bottom_bar.key_validator = self.is_key_available
|
||||
bottom_bar.key_bindings = self.key_bindings
|
||||
self._bottom_bar = bottom_bar
|
||||
elif isinstance(bottom_bar, str) or callable(bottom_bar):
|
||||
@ -503,17 +553,19 @@ class Falyx:
|
||||
def prompt_session(self) -> PromptSession:
|
||||
"""Returns the prompt session for the menu."""
|
||||
if self._prompt_session is None:
|
||||
placeholder = self.build_placeholder_menu()
|
||||
self._prompt_session = PromptSession(
|
||||
message=self.prompt,
|
||||
history=self.history,
|
||||
multiline=False,
|
||||
completer=self._get_completer(),
|
||||
reserve_space_for_menu=1,
|
||||
validator=CommandValidator(self, self._get_validator_error_message()),
|
||||
bottom_toolbar=self._get_bottom_bar_render(),
|
||||
key_bindings=self.key_bindings,
|
||||
validate_while_typing=True,
|
||||
interrupt_exception=QuitSignal,
|
||||
eof_exception=QuitSignal,
|
||||
placeholder=placeholder if self.show_placeholder_menu else None,
|
||||
)
|
||||
return self._prompt_session
|
||||
|
||||
@ -545,32 +597,9 @@ class Falyx:
|
||||
for key, command in self.commands.items():
|
||||
logger.debug("[Command '%s'] hooks:\n%s", key, str(command.hooks))
|
||||
|
||||
def is_key_available(self, key: str) -> bool:
|
||||
key = key.upper()
|
||||
toggles = (
|
||||
self._bottom_bar.toggle_keys
|
||||
if isinstance(self._bottom_bar, BottomBar)
|
||||
else []
|
||||
)
|
||||
|
||||
conflicts = (
|
||||
key in self.commands,
|
||||
key == self.exit_command.key.upper(),
|
||||
self.history_command and key == self.history_command.key.upper(),
|
||||
self.help_command and key == self.help_command.key.upper(),
|
||||
key in toggles,
|
||||
)
|
||||
|
||||
return not any(conflicts)
|
||||
|
||||
def _validate_command_key(self, key: str) -> None:
|
||||
"""Validates the command key to ensure it is unique."""
|
||||
key = key.upper()
|
||||
toggles = (
|
||||
self._bottom_bar.toggle_keys
|
||||
if isinstance(self._bottom_bar, BottomBar)
|
||||
else []
|
||||
)
|
||||
collisions = []
|
||||
|
||||
if key in self.commands:
|
||||
@ -581,8 +610,6 @@ class Falyx:
|
||||
collisions.append("history command")
|
||||
if self.help_command and key == self.help_command.key.upper():
|
||||
collisions.append("help command")
|
||||
if key in toggles:
|
||||
collisions.append("toggle")
|
||||
|
||||
if collisions:
|
||||
raise CommandAlreadyExistsError(
|
||||
@ -612,6 +639,9 @@ class Falyx:
|
||||
style=style,
|
||||
confirm=confirm,
|
||||
confirm_message=confirm_message,
|
||||
ignore_in_history=True,
|
||||
options_manager=self.options,
|
||||
program=self.program,
|
||||
)
|
||||
|
||||
def add_submenu(
|
||||
@ -666,7 +696,7 @@ class Falyx:
|
||||
spinner_message: str = "Processing...",
|
||||
spinner_type: str = "dots",
|
||||
spinner_style: str = OneColors.CYAN,
|
||||
spinner_kwargs: dict[str, Any] | None = None,
|
||||
spinner_speed: float = 1.0,
|
||||
hooks: HookManager | None = None,
|
||||
before_hooks: list[Callable] | None = None,
|
||||
success_hooks: list[Callable] | None = None,
|
||||
@ -686,6 +716,7 @@ class Falyx:
|
||||
auto_args: bool = True,
|
||||
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
|
||||
simple_help_signature: bool = False,
|
||||
ignore_in_history: bool = False,
|
||||
) -> Command:
|
||||
"""Adds an command to the menu, preventing duplicates."""
|
||||
self._validate_command_key(key)
|
||||
@ -715,7 +746,7 @@ class Falyx:
|
||||
spinner_message=spinner_message,
|
||||
spinner_type=spinner_type,
|
||||
spinner_style=spinner_style,
|
||||
spinner_kwargs=spinner_kwargs or {},
|
||||
spinner_speed=spinner_speed,
|
||||
tags=tags if tags else [],
|
||||
logging_hooks=logging_hooks,
|
||||
retry=retry,
|
||||
@ -730,6 +761,8 @@ class Falyx:
|
||||
auto_args=auto_args,
|
||||
arg_metadata=arg_metadata or {},
|
||||
simple_help_signature=simple_help_signature,
|
||||
ignore_in_history=ignore_in_history,
|
||||
program=self.program,
|
||||
)
|
||||
|
||||
if hooks:
|
||||
@ -748,6 +781,10 @@ class Falyx:
|
||||
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)
|
||||
|
||||
self.commands[key] = command
|
||||
return command
|
||||
|
||||
@ -757,16 +794,16 @@ class Falyx:
|
||||
if self.help_command:
|
||||
bottom_row.append(
|
||||
f"[{self.help_command.key}] [{self.help_command.style}]"
|
||||
f"{self.help_command.description}"
|
||||
f"{self.help_command.description}[/]"
|
||||
)
|
||||
if self.history_command:
|
||||
bottom_row.append(
|
||||
f"[{self.history_command.key}] [{self.history_command.style}]"
|
||||
f"{self.history_command.description}"
|
||||
f"{self.history_command.description}[/]"
|
||||
)
|
||||
bottom_row.append(
|
||||
f"[{self.exit_command.key}] [{self.exit_command.style}]"
|
||||
f"{self.exit_command.description}"
|
||||
f"{self.exit_command.description}[/]"
|
||||
)
|
||||
return bottom_row
|
||||
|
||||
@ -787,6 +824,22 @@ class Falyx:
|
||||
table.add_row(*row)
|
||||
return table
|
||||
|
||||
def build_placeholder_menu(self) -> StyleAndTextTuples:
|
||||
"""
|
||||
Builds a menu placeholder for show_placeholder_menu.
|
||||
"""
|
||||
visible_commands = [item for item in self.commands.items() if not item[1].hidden]
|
||||
if not visible_commands:
|
||||
return [("", "")]
|
||||
|
||||
placeholder: list[str] = []
|
||||
for key, command in visible_commands:
|
||||
placeholder.append(f"[{key}] [{command.style}]{command.description}[/]")
|
||||
for command_str in self.get_bottom_row():
|
||||
placeholder.append(command_str)
|
||||
|
||||
return rich_text_to_prompt_text(" ".join(placeholder))
|
||||
|
||||
@property
|
||||
def table(self) -> Table:
|
||||
"""Creates or returns a custom table to display the menu commands."""
|
||||
@ -849,13 +902,17 @@ class Falyx:
|
||||
logger.info("Command '%s' selected.", run_command.key)
|
||||
if is_preview:
|
||||
return True, run_command, args, kwargs
|
||||
elif self.mode in {FalyxMode.RUN, FalyxMode.RUN_ALL, FalyxMode.PREVIEW}:
|
||||
elif self.options.get("mode") in {
|
||||
FalyxMode.RUN,
|
||||
FalyxMode.RUN_ALL,
|
||||
FalyxMode.PREVIEW,
|
||||
}:
|
||||
return False, run_command, args, kwargs
|
||||
try:
|
||||
args, kwargs = await run_command.parse_args(input_args, from_validate)
|
||||
except (CommandArgumentError, Exception) as error:
|
||||
if not from_validate:
|
||||
run_command.show_help()
|
||||
run_command.render_help()
|
||||
self.console.print(
|
||||
f"[{OneColors.DARK_RED}]❌ [{run_command.key}]: {error}"
|
||||
)
|
||||
@ -911,7 +968,13 @@ class Falyx:
|
||||
self, selected_command: Command, error: Exception
|
||||
) -> None:
|
||||
"""Handles errors that occur during the action of the selected command."""
|
||||
logger.exception("Error executing '%s': %s", selected_command.description, error)
|
||||
logger.debug(
|
||||
"[%s] '%s' failed with error: %s",
|
||||
selected_command.key,
|
||||
selected_command.description,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
self.console.print(
|
||||
f"[{OneColors.DARK_RED}]An error occurred while executing "
|
||||
f"{selected_command.description}:[/] {error}"
|
||||
@ -919,6 +982,9 @@ class Falyx:
|
||||
|
||||
async def process_command(self) -> bool:
|
||||
"""Processes the action of the selected command."""
|
||||
app = get_app()
|
||||
await asyncio.sleep(0.1)
|
||||
app.invalidate()
|
||||
with patch_stdout(raw=True):
|
||||
choice = await self.prompt_session.prompt_async()
|
||||
is_preview, selected_command, args, kwargs = await self.get_command(choice)
|
||||
@ -1001,12 +1067,7 @@ class Falyx:
|
||||
except Exception as error:
|
||||
context.exception = error
|
||||
await self.hooks.trigger(HookType.ON_ERROR, context)
|
||||
logger.error(
|
||||
"[run_key] Failed: %s — %s: %s",
|
||||
selected_command.description,
|
||||
type(error).__name__,
|
||||
error,
|
||||
)
|
||||
await self._handle_action_error(selected_command, error)
|
||||
raise FalyxError(
|
||||
f"[run_key] ❌ '{selected_command.description}' failed."
|
||||
) from error
|
||||
@ -1118,7 +1179,8 @@ class Falyx:
|
||||
if callback:
|
||||
if not callable(callback):
|
||||
raise FalyxError("Callback must be a callable function.")
|
||||
callback(self.cli_args)
|
||||
async_callback = ensure_async(callback)
|
||||
await async_callback(self.cli_args)
|
||||
|
||||
if not self.options.get("never_prompt"):
|
||||
self.options.set("never_prompt", self._never_prompt)
|
||||
@ -1137,7 +1199,7 @@ class Falyx:
|
||||
self.register_all_with_debug_hooks()
|
||||
|
||||
if self.cli_args.command == "list":
|
||||
await self._show_help(tag=self.cli_args.tag)
|
||||
await self._render_help(tag=self.cli_args.tag)
|
||||
sys.exit(0)
|
||||
|
||||
if self.cli_args.command == "version" or self.cli_args.version:
|
||||
@ -1145,7 +1207,7 @@ class Falyx:
|
||||
sys.exit(0)
|
||||
|
||||
if self.cli_args.command == "preview":
|
||||
self.mode = FalyxMode.PREVIEW
|
||||
self.options.set("mode", FalyxMode.PREVIEW)
|
||||
_, command, args, kwargs = await self.get_command(self.cli_args.name)
|
||||
if not command:
|
||||
self.console.print(
|
||||
@ -1159,7 +1221,7 @@ class Falyx:
|
||||
sys.exit(0)
|
||||
|
||||
if self.cli_args.command == "run":
|
||||
self.mode = FalyxMode.RUN
|
||||
self.options.set("mode", FalyxMode.RUN)
|
||||
is_preview, command, _, __ = await self.get_command(self.cli_args.name)
|
||||
if is_preview:
|
||||
if command is None:
|
||||
@ -1176,7 +1238,7 @@ class Falyx:
|
||||
sys.exit(0)
|
||||
except CommandArgumentError as error:
|
||||
self.console.print(f"[{OneColors.DARK_RED}]❌ ['{command.key}'] {error}")
|
||||
command.show_help()
|
||||
command.render_help()
|
||||
sys.exit(1)
|
||||
try:
|
||||
await self.run_key(self.cli_args.name, args=args, kwargs=kwargs)
|
||||
@ -1198,7 +1260,7 @@ class Falyx:
|
||||
sys.exit(0)
|
||||
|
||||
if self.cli_args.command == "run-all":
|
||||
self.mode = FalyxMode.RUN_ALL
|
||||
self.options.set("mode", FalyxMode.RUN_ALL)
|
||||
matching = [
|
||||
cmd
|
||||
for cmd in self.commands.values()
|
||||
|
@ -1,5 +1,21 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""hook_manager.py"""
|
||||
"""
|
||||
Defines the `HookManager` and `HookType` used in the Falyx CLI framework to manage
|
||||
execution lifecycle hooks around actions and commands.
|
||||
|
||||
The hook system enables structured callbacks for important stages in a Falyx action's
|
||||
execution, such as before execution, after success, upon error, and teardown. These
|
||||
can be used for logging, side effects, diagnostics, metrics, and rollback logic.
|
||||
|
||||
Key Components:
|
||||
- HookType: Enum categorizing supported hook lifecycle stages
|
||||
- HookManager: Core class for registering and invoking hooks during action execution
|
||||
- Hook: Union of sync and async callables accepting an `ExecutionContext`
|
||||
|
||||
Usage:
|
||||
hooks = HookManager()
|
||||
hooks.register(HookType.BEFORE, log_before)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
@ -15,7 +31,27 @@ Hook = Union[
|
||||
|
||||
|
||||
class HookType(Enum):
|
||||
"""Enum for hook types to categorize the hooks."""
|
||||
"""
|
||||
Enum for supported hook lifecycle phases in Falyx.
|
||||
|
||||
HookType is used to classify lifecycle events that can be intercepted
|
||||
with user-defined callbacks.
|
||||
|
||||
Members:
|
||||
BEFORE: Run before the action is invoked.
|
||||
ON_SUCCESS: Run after successful completion.
|
||||
ON_ERROR: Run when an exception occurs.
|
||||
AFTER: Run after success or failure (always runs).
|
||||
ON_TEARDOWN: Run at the very end, for resource cleanup.
|
||||
|
||||
Aliases:
|
||||
"success" → "on_success"
|
||||
"error" → "on_error"
|
||||
"teardown" → "on_teardown"
|
||||
|
||||
Example:
|
||||
HookType("error") → HookType.ON_ERROR
|
||||
"""
|
||||
|
||||
BEFORE = "before"
|
||||
ON_SUCCESS = "on_success"
|
||||
@ -28,13 +64,49 @@ class HookType(Enum):
|
||||
"""Return a list of all hook type choices."""
|
||||
return list(cls)
|
||||
|
||||
@classmethod
|
||||
def _get_alias(cls, value: str) -> str:
|
||||
aliases = {
|
||||
"success": "on_success",
|
||||
"error": "on_error",
|
||||
"teardown": "on_teardown",
|
||||
}
|
||||
return aliases.get(value, value)
|
||||
|
||||
@classmethod
|
||||
def _missing_(cls, value: object) -> HookType:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"Invalid {cls.__name__}: {value!r}")
|
||||
normalized = value.strip().lower()
|
||||
alias = cls._get_alias(normalized)
|
||||
for member in cls:
|
||||
if member.value == alias:
|
||||
return member
|
||||
valid = ", ".join(member.value for member in cls)
|
||||
raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}")
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return the string representation of the hook type."""
|
||||
return self.value
|
||||
|
||||
|
||||
class HookManager:
|
||||
"""HookManager"""
|
||||
"""
|
||||
Manages lifecycle hooks for a command or action.
|
||||
|
||||
`HookManager` tracks user-defined callbacks to be run at key points in a command's
|
||||
lifecycle: before execution, on success, on error, after completion, and during
|
||||
teardown. Both sync and async hooks are supported.
|
||||
|
||||
Methods:
|
||||
register(hook_type, hook): Register a callable for a given HookType.
|
||||
clear(hook_type): Remove hooks for one or all lifecycle stages.
|
||||
trigger(hook_type, context): Execute all hooks of a given type.
|
||||
|
||||
Example:
|
||||
hooks = HookManager()
|
||||
hooks.register(HookType.BEFORE, my_logger)
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._hooks: dict[HookType, list[Hook]] = {
|
||||
@ -42,12 +114,26 @@ class HookManager:
|
||||
}
|
||||
|
||||
def register(self, hook_type: HookType | str, hook: Hook):
|
||||
"""Raises ValueError if the hook type is not supported."""
|
||||
if not isinstance(hook_type, HookType):
|
||||
"""
|
||||
Register a new hook for a given lifecycle phase.
|
||||
|
||||
Args:
|
||||
hook_type (HookType | str): The hook category (e.g. "before", "on_success").
|
||||
hook (Callable): The hook function to register.
|
||||
|
||||
Raises:
|
||||
ValueError: If the hook type is invalid.
|
||||
"""
|
||||
hook_type = HookType(hook_type)
|
||||
self._hooks[hook_type].append(hook)
|
||||
|
||||
def clear(self, hook_type: HookType | None = None):
|
||||
"""
|
||||
Clear registered hooks for one or all hook types.
|
||||
|
||||
Args:
|
||||
hook_type (HookType | None): If None, clears all hooks.
|
||||
"""
|
||||
if hook_type:
|
||||
self._hooks[hook_type] = []
|
||||
else:
|
||||
@ -55,6 +141,17 @@ class HookManager:
|
||||
self._hooks[ht] = []
|
||||
|
||||
async def trigger(self, hook_type: HookType, context: ExecutionContext):
|
||||
"""
|
||||
Invoke all hooks registered for a given lifecycle phase.
|
||||
|
||||
Args:
|
||||
hook_type (HookType): The lifecycle phase to trigger.
|
||||
context (ExecutionContext): The execution context passed to each hook.
|
||||
|
||||
Raises:
|
||||
Exception: Re-raises the original context.exception if a hook fails during
|
||||
ON_ERROR. Other hook exceptions are logged and skipped.
|
||||
"""
|
||||
if hook_type not in self._hooks:
|
||||
raise ValueError(f"Unsupported hook type: {hook_type}")
|
||||
for hook in self._hooks[hook_type]:
|
||||
@ -71,7 +168,6 @@ class HookManager:
|
||||
context.name,
|
||||
hook_error,
|
||||
)
|
||||
|
||||
if hook_type == HookType.ON_ERROR:
|
||||
assert isinstance(
|
||||
context.exception, Exception
|
||||
|
@ -1,5 +1,32 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""hooks.py"""
|
||||
"""
|
||||
Defines reusable lifecycle hooks for Falyx Actions and Commands.
|
||||
|
||||
This module includes:
|
||||
- `spinner_before_hook`: Automatically starts a spinner before an action runs.
|
||||
- `spinner_teardown_hook`: Stops and clears the spinner after the action completes.
|
||||
- `ResultReporter`: A success hook that displays a formatted result with duration.
|
||||
- `CircuitBreaker`: A failure-aware hook manager that prevents repeated execution
|
||||
after a configurable number of failures.
|
||||
|
||||
These hooks can be registered on `HookManager` instances via lifecycle stages
|
||||
(`before`, `on_error`, `after`, etc.) to enhance resiliency and observability.
|
||||
|
||||
Intended for use with:
|
||||
- Actions that require user feedback during long-running operations.
|
||||
- Retryable or unstable actions
|
||||
- Interactive CLI feedback
|
||||
- Safety checks prior to execution
|
||||
|
||||
Example usage:
|
||||
breaker = CircuitBreaker(max_failures=3)
|
||||
hooks.register(HookType.BEFORE, breaker.before_hook)
|
||||
hooks.register(HookType.ON_ERROR, breaker.error_hook)
|
||||
hooks.register(HookType.AFTER, breaker.after_hook)
|
||||
|
||||
reporter = ResultReporter()
|
||||
hooks.register(HookType.ON_SUCCESS, reporter.report)
|
||||
"""
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
|
||||
@ -9,6 +36,38 @@ from falyx.logger import logger
|
||||
from falyx.themes import OneColors
|
||||
|
||||
|
||||
async def spinner_before_hook(context: ExecutionContext):
|
||||
"""Adds a spinner before the action starts."""
|
||||
cmd = context.action
|
||||
if cmd.options_manager is None:
|
||||
return
|
||||
sm = context.action.options_manager.spinners
|
||||
if hasattr(cmd, "name"):
|
||||
cmd_name = cmd.name
|
||||
else:
|
||||
cmd_name = cmd.key
|
||||
await sm.add(
|
||||
cmd_name,
|
||||
cmd.spinner_message,
|
||||
cmd.spinner_type,
|
||||
cmd.spinner_style,
|
||||
cmd.spinner_speed,
|
||||
)
|
||||
|
||||
|
||||
async def spinner_teardown_hook(context: ExecutionContext):
|
||||
"""Removes the spinner after the action finishes (success or failure)."""
|
||||
cmd = context.action
|
||||
if cmd.options_manager is None:
|
||||
return
|
||||
if hasattr(cmd, "name"):
|
||||
cmd_name = cmd.name
|
||||
else:
|
||||
cmd_name = cmd.key
|
||||
sm = context.action.options_manager.spinners
|
||||
await sm.remove(cmd_name)
|
||||
|
||||
|
||||
class ResultReporter:
|
||||
"""Reports the success of an action."""
|
||||
|
||||
|
@ -1,5 +1,23 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""init.py"""
|
||||
"""
|
||||
Project and global initializer for Falyx CLI environments.
|
||||
|
||||
This module defines functions to bootstrap a new Falyx-based CLI project or
|
||||
create a global user-level configuration in `~/.config/falyx`.
|
||||
|
||||
Functions:
|
||||
- `init_project(name: str)`: Creates a new CLI project folder with `tasks.py`
|
||||
and `falyx.yaml` using example actions and config structure.
|
||||
- `init_global()`: Creates a shared config in the user's home directory for
|
||||
defining reusable or always-available CLI commands.
|
||||
|
||||
Generated files include:
|
||||
- `tasks.py`: Python module with `Action`, `ChainedAction`, and async examples
|
||||
- `falyx.yaml`: YAML config with command definitions for CLI entry points
|
||||
|
||||
Used by:
|
||||
- The `falyx init` and `falyx init --global` commands
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from falyx.console import console
|
||||
|
@ -1,5 +1,5 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""logger.py"""
|
||||
"""Global logger instance for Falyx CLI applications."""
|
||||
import logging
|
||||
|
||||
logger: logging.Logger = logging.getLogger("falyx")
|
||||
|
@ -1,3 +1,20 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""
|
||||
Defines `MenuOption` and `MenuOptionMap`, core components used to construct
|
||||
interactive menus within Falyx Actions such as `MenuAction` and `PromptMenuAction`.
|
||||
|
||||
Each `MenuOption` represents a single actionable choice with a description,
|
||||
styling, and a bound `BaseAction`. `MenuOptionMap` manages collections of these
|
||||
options, including support for reserved keys like `B` (Back) and `X` (Exit), which
|
||||
can trigger navigation signals when selected.
|
||||
|
||||
These constructs enable declarative and reusable menu definitions in both code and config.
|
||||
|
||||
Key Components:
|
||||
- MenuOption: A user-facing label and action binding
|
||||
- MenuOptionMap: A key-aware container for menu options, with reserved entry support
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
@ -12,7 +29,25 @@ from falyx.utils import CaseInsensitiveDict
|
||||
|
||||
@dataclass
|
||||
class MenuOption:
|
||||
"""Represents a single menu option with a description and an action to execute."""
|
||||
"""
|
||||
Represents a single menu entry, including its label and associated action.
|
||||
|
||||
Used in conjunction with `MenuOptionMap` to define interactive command menus.
|
||||
Each `MenuOption` contains a description (shown to the user), a `BaseAction`
|
||||
to execute when selected, and an optional Rich-compatible style.
|
||||
|
||||
Attributes:
|
||||
description (str): The label shown next to the menu key.
|
||||
action (BaseAction): The action to invoke when selected.
|
||||
style (str): A Rich-compatible color/style string for UI display.
|
||||
|
||||
Methods:
|
||||
render(key): Returns a Rich-formatted string for menu display.
|
||||
render_prompt(key): Returns a `FormattedText` object for use in prompt placeholders.
|
||||
|
||||
Raises:
|
||||
TypeError: If `description` is not a string or `action` is not a `BaseAction`.
|
||||
"""
|
||||
|
||||
description: str
|
||||
action: BaseAction
|
||||
@ -37,8 +72,27 @@ class MenuOption:
|
||||
|
||||
class MenuOptionMap(CaseInsensitiveDict):
|
||||
"""
|
||||
Manages menu options including validation, reserved key protection,
|
||||
and special signal entries like Quit and Back.
|
||||
A container for storing and managing `MenuOption` objects by key.
|
||||
|
||||
`MenuOptionMap` is used to define the set of available choices in a
|
||||
Falyx menu. Keys are case-insensitive and mapped to `MenuOption` instances.
|
||||
The map supports special reserved keys—`B` for Back and `X` for Exit—unless
|
||||
explicitly disabled via `allow_reserved=False`.
|
||||
|
||||
This class enforces strict typing of menu options and prevents accidental
|
||||
overwrites of reserved keys.
|
||||
|
||||
Args:
|
||||
options (dict[str, MenuOption] | None): Initial options to populate the menu.
|
||||
allow_reserved (bool): If True, allows overriding reserved keys.
|
||||
|
||||
Methods:
|
||||
items(include_reserved): Returns an iterable of menu options,
|
||||
optionally filtering out reserved keys.
|
||||
|
||||
Raises:
|
||||
TypeError: If non-`MenuOption` values are assigned.
|
||||
ValueError: If attempting to use or delete a reserved key without permission.
|
||||
"""
|
||||
|
||||
RESERVED_KEYS = {"B", "X"}
|
||||
|
12
falyx/mode.py
Normal file
12
falyx/mode.py
Normal file
@ -0,0 +1,12 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""
|
||||
Defines `FalyxMode`, an enum representing the different modes of operation for Falyx.
|
||||
"""
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class FalyxMode(Enum):
|
||||
MENU = "menu"
|
||||
RUN = "run"
|
||||
PREVIEW = "preview"
|
||||
RUN_ALL = "run-all"
|
@ -1,18 +1,55 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""options_manager.py"""
|
||||
"""
|
||||
Manages global or scoped CLI options across namespaces for Falyx commands.
|
||||
|
||||
The `OptionsManager` provides a centralized interface for retrieving, setting, toggling,
|
||||
and introspecting options defined in `argparse.Namespace` objects. It is used internally
|
||||
by Falyx to pass and resolve runtime flags like `--verbose`, `--force-confirm`, etc.
|
||||
|
||||
Each option is stored under a namespace key (e.g., "cli_args", "user_config") to
|
||||
support multiple sources of configuration.
|
||||
|
||||
Key Features:
|
||||
- Safe getter/setter for typed option resolution
|
||||
- Toggle support for boolean options (used by bottom bar toggles, etc.)
|
||||
- Callable getter/toggler wrappers for dynamic UI bindings
|
||||
- Namespace merging via `from_namespace`
|
||||
|
||||
Typical Usage:
|
||||
options = OptionsManager()
|
||||
options.from_namespace(args, namespace_name="cli_args")
|
||||
if options.get("verbose"):
|
||||
...
|
||||
options.toggle("force_confirm")
|
||||
value_fn = options.get_value_getter("dry_run")
|
||||
toggle_fn = options.get_toggle_function("debug")
|
||||
|
||||
Used by:
|
||||
- Falyx CLI runtime configuration
|
||||
- Bottom bar toggles
|
||||
- Dynamic flag injection into commands and actions
|
||||
"""
|
||||
|
||||
from argparse import Namespace
|
||||
from collections import defaultdict
|
||||
from typing import Any, Callable
|
||||
|
||||
from falyx.logger import logger
|
||||
from falyx.spinner_manager import SpinnerManager
|
||||
|
||||
|
||||
class OptionsManager:
|
||||
"""OptionsManager"""
|
||||
"""
|
||||
Manages CLI option state across multiple argparse namespaces.
|
||||
|
||||
Allows dynamic retrieval, setting, toggling, and introspection of command-line
|
||||
options. Supports named namespaces (e.g., "cli_args") and is used throughout
|
||||
Falyx for runtime configuration and bottom bar toggle integration.
|
||||
"""
|
||||
|
||||
def __init__(self, namespaces: list[tuple[str, Namespace]] | None = None) -> None:
|
||||
self.options: defaultdict = defaultdict(Namespace)
|
||||
self.spinners = SpinnerManager()
|
||||
if namespaces:
|
||||
for namespace_name, namespace in namespaces:
|
||||
self.from_namespace(namespace, namespace_name)
|
||||
|
@ -1,5 +1,38 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""argument.py"""
|
||||
"""
|
||||
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
|
||||
|
||||
@ -26,6 +59,7 @@ class Argument:
|
||||
resolver (BaseAction | None):
|
||||
An action object that resolves the argument, if applicable.
|
||||
lazy_resolver (bool): True if the resolver should be called lazily, False otherwise
|
||||
suggestions (list[str] | None): Optional completions for interactive shells
|
||||
"""
|
||||
|
||||
flags: tuple[str, ...]
|
||||
@ -40,6 +74,7 @@ class Argument:
|
||||
positional: bool = False
|
||||
resolver: BaseAction | None = None
|
||||
lazy_resolver: bool = False
|
||||
suggestions: list[str] | None = None
|
||||
|
||||
def get_positional_text(self) -> str:
|
||||
"""Get the positional text for the argument."""
|
||||
|
@ -1,12 +1,56 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""argument_action.py"""
|
||||
"""
|
||||
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."""
|
||||
"""
|
||||
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"
|
||||
@ -17,12 +61,34 @@ class ArgumentAction(Enum):
|
||||
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
|
||||
|
@ -1,20 +1,69 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""command_argument_parser.py"""
|
||||
"""
|
||||
This module implements `CommandArgumentParser`, a flexible, rich-aware alternative to
|
||||
argparse tailored specifically for Falyx CLI workflows. It provides structured parsing,
|
||||
type coercion, flag support, and usage/help rendering for CLI-defined commands.
|
||||
|
||||
Unlike argparse, this parser is lightweight, introspectable, and designed to integrate
|
||||
deeply with Falyx's Action system, including support for lazy execution and resolver
|
||||
binding via `BaseAction`.
|
||||
|
||||
Key Features:
|
||||
- Declarative argument registration via `add_argument()`
|
||||
- Support for positional and keyword flags, type coercion, default values
|
||||
- Enum- and action-driven argument semantics via `ArgumentAction`
|
||||
- Lazy evaluation of arguments using Falyx `Action` resolvers
|
||||
- Optional value completion via suggestions and choices
|
||||
- Rich-powered help rendering with grouped display
|
||||
- Optional boolean flags via `--flag` / `--no-flag`
|
||||
- POSIX-style bundling for single-character flags (`-abc`)
|
||||
- Partial parsing for completions and validation via `suggest_next()`
|
||||
|
||||
Public Interface:
|
||||
- `add_argument(...)`: Register a new argument with type, flags, and behavior.
|
||||
- `parse_args(...)`: Parse CLI-style argument list into a `dict[str, Any]`.
|
||||
- `parse_args_split(...)`: Return `(*args, **kwargs)` for Action invocation.
|
||||
- `render_help()`: Render a rich-styled help panel.
|
||||
- `render_tldr()`: Render quick usage examples.
|
||||
- `suggest_next(...)`: Return suggested flags or values for completion.
|
||||
|
||||
Example Usage:
|
||||
parser = CommandArgumentParser(command_key="D")
|
||||
parser.add_argument("--env", choices=["prod", "dev"], required=True)
|
||||
parser.add_argument("path", type=Path)
|
||||
|
||||
args = await parser.parse_args(["--env", "prod", "./config.yml"])
|
||||
|
||||
# args == {'env': 'prod', 'path': Path('./config.yml')}
|
||||
|
||||
parser.render_help() # Pretty Rich output
|
||||
|
||||
Design Notes:
|
||||
This parser intentionally omits argparse-style groups, metavar support,
|
||||
and complex multi-level conflict handling. Instead, it favors:
|
||||
- Simplicity
|
||||
- Completeness
|
||||
- Falyx-specific integration (hooks, lifecycle, and error surfaces)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from collections import Counter, defaultdict
|
||||
from copy import deepcopy
|
||||
from typing import Any, Iterable
|
||||
from typing import Any, Iterable, Sequence
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
from rich.padding import Padding
|
||||
from rich.panel import Panel
|
||||
|
||||
from falyx.action.base_action import BaseAction
|
||||
from falyx.console import console
|
||||
from falyx.exceptions import CommandArgumentError
|
||||
from falyx.mode import FalyxMode
|
||||
from falyx.options_manager import OptionsManager
|
||||
from falyx.parser.argument import Argument
|
||||
from falyx.parser.argument_action import ArgumentAction
|
||||
from falyx.parser.parser_types import false_none, true_none
|
||||
from falyx.parser.parser_types import ArgumentState, TLDRExample, false_none, true_none
|
||||
from falyx.parser.utils import coerce_value
|
||||
from falyx.signals import HelpSignal
|
||||
|
||||
@ -40,6 +89,8 @@ class CommandArgumentParser:
|
||||
- Render Help using Rich library.
|
||||
"""
|
||||
|
||||
RESERVED_DESTS = frozenset(("help", "tldr"))
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
command_key: str = "",
|
||||
@ -48,6 +99,9 @@ class CommandArgumentParser:
|
||||
help_text: str = "",
|
||||
help_epilog: str = "",
|
||||
aliases: list[str] | None = None,
|
||||
tldr_examples: list[tuple[str, str]] | None = None,
|
||||
program: str | None = None,
|
||||
options_manager: OptionsManager | None = None,
|
||||
) -> None:
|
||||
"""Initialize the CommandArgumentParser."""
|
||||
self.console: Console = console
|
||||
@ -57,6 +111,7 @@ class CommandArgumentParser:
|
||||
self.help_text: str = help_text
|
||||
self.help_epilog: str = help_epilog
|
||||
self.aliases: list[str] = aliases or []
|
||||
self.program: str | None = program
|
||||
self._arguments: list[Argument] = []
|
||||
self._positional: dict[str, Argument] = {}
|
||||
self._keyword: dict[str, Argument] = {}
|
||||
@ -64,16 +119,48 @@ class CommandArgumentParser:
|
||||
self._flag_map: dict[str, Argument] = {}
|
||||
self._dest_set: set[str] = set()
|
||||
self._add_help()
|
||||
self._last_positional_states: dict[str, ArgumentState] = {}
|
||||
self._last_keyword_states: dict[str, ArgumentState] = {}
|
||||
self._tldr_examples: list[TLDRExample] = []
|
||||
if tldr_examples:
|
||||
self.add_tldr_examples(tldr_examples)
|
||||
self.options_manager: OptionsManager = options_manager or OptionsManager()
|
||||
|
||||
def _add_help(self):
|
||||
"""Add help argument to the parser."""
|
||||
self.add_argument(
|
||||
"-h",
|
||||
"--help",
|
||||
help = Argument(
|
||||
flags=("--help", "-h"),
|
||||
action=ArgumentAction.HELP,
|
||||
help="Show this help message.",
|
||||
dest="help",
|
||||
)
|
||||
self._register_argument(help)
|
||||
|
||||
def add_tldr_examples(self, examples: list[tuple[str, str]]) -> None:
|
||||
"""
|
||||
Add TLDR examples to the parser.
|
||||
|
||||
Args:
|
||||
examples (list[tuple[str, str]]): List of (usage, description) tuples.
|
||||
"""
|
||||
if not all(
|
||||
isinstance(example, tuple) and len(example) == 2 for example in examples
|
||||
):
|
||||
raise CommandArgumentError(
|
||||
"TLDR examples must be a list of (usage, description) tuples"
|
||||
)
|
||||
|
||||
for usage, description in examples:
|
||||
self._tldr_examples.append(TLDRExample(usage=usage, description=description))
|
||||
|
||||
if "tldr" not in self._dest_set:
|
||||
tldr = Argument(
|
||||
("--tldr",),
|
||||
action=ArgumentAction.TLDR,
|
||||
help="Show quick usage examples and exit.",
|
||||
dest="tldr",
|
||||
)
|
||||
self._register_argument(tldr)
|
||||
|
||||
def _is_positional(self, flags: tuple[str, ...]) -> bool:
|
||||
"""Check if the flags are positional."""
|
||||
@ -127,6 +214,7 @@ class CommandArgumentParser:
|
||||
ArgumentAction.STORE_FALSE,
|
||||
ArgumentAction.STORE_BOOL_OPTIONAL,
|
||||
ArgumentAction.HELP,
|
||||
ArgumentAction.TLDR,
|
||||
):
|
||||
raise CommandArgumentError(
|
||||
f"Argument with action {action} cannot be required"
|
||||
@ -159,6 +247,7 @@ class CommandArgumentParser:
|
||||
ArgumentAction.STORE_TRUE,
|
||||
ArgumentAction.COUNT,
|
||||
ArgumentAction.HELP,
|
||||
ArgumentAction.TLDR,
|
||||
ArgumentAction.STORE_BOOL_OPTIONAL,
|
||||
):
|
||||
if nargs is not None:
|
||||
@ -267,6 +356,7 @@ class CommandArgumentParser:
|
||||
ArgumentAction.STORE_BOOL_OPTIONAL,
|
||||
ArgumentAction.COUNT,
|
||||
ArgumentAction.HELP,
|
||||
ArgumentAction.TLDR,
|
||||
):
|
||||
if positional:
|
||||
raise CommandArgumentError(
|
||||
@ -359,19 +449,19 @@ class CommandArgumentParser:
|
||||
)
|
||||
|
||||
self._register_argument(argument)
|
||||
self._register_argument(negated_argument)
|
||||
self._register_argument(negated_argument, bypass_validation=True)
|
||||
|
||||
def _register_argument(self, argument: Argument):
|
||||
def _register_argument(
|
||||
self, argument: Argument, bypass_validation: bool = False
|
||||
) -> None:
|
||||
|
||||
for flag in argument.flags:
|
||||
if (
|
||||
flag in self._flag_map
|
||||
and not argument.action == ArgumentAction.STORE_BOOL_OPTIONAL
|
||||
):
|
||||
if flag in self._flag_map and not bypass_validation:
|
||||
existing = self._flag_map[flag]
|
||||
raise CommandArgumentError(
|
||||
f"Flag '{flag}' is already used by argument '{existing.dest}'"
|
||||
)
|
||||
|
||||
for flag in argument.flags:
|
||||
self._flag_map[flag] = argument
|
||||
if not argument.positional:
|
||||
@ -380,6 +470,9 @@ class CommandArgumentParser:
|
||||
self._arguments.append(argument)
|
||||
if argument.positional:
|
||||
self._positional[argument.dest] = argument
|
||||
else:
|
||||
if argument.action == ArgumentAction.TLDR:
|
||||
self._keyword_list.insert(1, argument)
|
||||
else:
|
||||
self._keyword_list.append(argument)
|
||||
|
||||
@ -396,25 +489,27 @@ class CommandArgumentParser:
|
||||
dest: str | None = None,
|
||||
resolver: BaseAction | None = None,
|
||||
lazy_resolver: bool = True,
|
||||
suggestions: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Add an argument to the parser.
|
||||
For `ArgumentAction.ACTION`, `nargs` and `type` determine how many and what kind
|
||||
of inputs are passed to the `resolver`.
|
||||
"""
|
||||
Define a new argument for the parser.
|
||||
|
||||
The return value of the `resolver` is used directly (no type coercion is applied).
|
||||
Validation, structure, and post-processing should be handled within the `resolver`.
|
||||
Supports positional and flagged arguments, type coercion, default values,
|
||||
validation rules, and optional resolution via `BaseAction`.
|
||||
|
||||
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().
|
||||
resolver: A BaseAction called with optional nargs specified parsed arguments.
|
||||
*flags (str): The flag(s) or name identifying the argument (e.g., "-v", "--verbose").
|
||||
action (str | ArgumentAction): The argument action type (default: "store").
|
||||
nargs (int | str | None): Number of values the argument consumes.
|
||||
default (Any): Default value if the argument is not provided.
|
||||
type (type): Type to coerce argument values to.
|
||||
choices (Iterable | None): Optional set of allowed values.
|
||||
required (bool): Whether this argument is mandatory.
|
||||
help (str): Help text for rendering in command help.
|
||||
dest (str | None): Custom destination key in result dict.
|
||||
resolver (BaseAction | None): If action="action", the BaseAction to call.
|
||||
lazy_resolver (bool): If True, resolver defers until action is triggered.
|
||||
suggestions (list[str] | None): Optional suggestions for interactive completion.
|
||||
"""
|
||||
expected_type = type
|
||||
self._validate_flags(flags)
|
||||
@ -426,6 +521,10 @@ class CommandArgumentParser:
|
||||
"Merging multiple arguments into the same dest (e.g. positional + flagged) "
|
||||
"is not supported. Define a unique 'dest' for each argument."
|
||||
)
|
||||
if dest in self.RESERVED_DESTS:
|
||||
raise CommandArgumentError(
|
||||
f"Destination '{dest}' is reserved and cannot be used."
|
||||
)
|
||||
action = self._validate_action(action, positional)
|
||||
resolver = self._validate_resolver(action, resolver)
|
||||
|
||||
@ -445,6 +544,10 @@ class CommandArgumentParser:
|
||||
f"Default value '{default}' not in allowed choices: {choices}"
|
||||
)
|
||||
required = self._determine_required(required, positional, nargs, action)
|
||||
if not isinstance(suggestions, Sequence) and suggestions is not None:
|
||||
raise CommandArgumentError(
|
||||
f"suggestions must be a list or None, got {type(suggestions)}"
|
||||
)
|
||||
if not isinstance(lazy_resolver, bool):
|
||||
raise CommandArgumentError(
|
||||
f"lazy_resolver must be a boolean, got {type(lazy_resolver)}"
|
||||
@ -465,13 +568,29 @@ class CommandArgumentParser:
|
||||
positional=positional,
|
||||
resolver=resolver,
|
||||
lazy_resolver=lazy_resolver,
|
||||
suggestions=suggestions,
|
||||
)
|
||||
self._register_argument(argument)
|
||||
|
||||
def get_argument(self, dest: str) -> Argument | None:
|
||||
"""
|
||||
Return the Argument object for a given destination name.
|
||||
|
||||
Args:
|
||||
dest (str): Destination key of the argument.
|
||||
|
||||
Returns:
|
||||
Argument or None: Matching Argument instance, if defined.
|
||||
"""
|
||||
return next((a for a in self._arguments if a.dest == dest), None)
|
||||
|
||||
def to_definition_list(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Convert argument metadata into a serializable list of dicts.
|
||||
|
||||
Returns:
|
||||
List of definitions for use in config introspection, documentation, or export.
|
||||
"""
|
||||
defs = []
|
||||
for arg in self._arguments:
|
||||
defs.append(
|
||||
@ -490,6 +609,46 @@ class CommandArgumentParser:
|
||||
)
|
||||
return defs
|
||||
|
||||
def _check_if_in_choices(
|
||||
self,
|
||||
spec: Argument,
|
||||
result: dict[str, Any],
|
||||
arg_states: dict[str, ArgumentState],
|
||||
) -> None:
|
||||
if not spec.choices:
|
||||
return None
|
||||
value_check = result.get(spec.dest)
|
||||
if isinstance(value_check, list):
|
||||
for value in value_check:
|
||||
if value in spec.choices:
|
||||
return None
|
||||
if value_check in spec.choices:
|
||||
return None
|
||||
raise CommandArgumentError(
|
||||
f"Invalid value for '{spec.dest}': must be one of {{{', '.join(spec.choices)}}}"
|
||||
)
|
||||
|
||||
def _raise_remaining_args_error(
|
||||
self, token: str, arg_states: dict[str, ArgumentState]
|
||||
) -> None:
|
||||
consumed_dests = [
|
||||
state.arg.dest for state in arg_states.values() if state.consumed
|
||||
]
|
||||
remaining_flags = [
|
||||
flag
|
||||
for flag, arg in self._keyword.items()
|
||||
if arg.dest not in consumed_dests and flag.startswith(token)
|
||||
]
|
||||
|
||||
if remaining_flags:
|
||||
raise CommandArgumentError(
|
||||
f"Unrecognized option '{token}'. Did you mean one of: {', '.join(remaining_flags)}?"
|
||||
)
|
||||
else:
|
||||
raise CommandArgumentError(
|
||||
f"Unrecognized option '{token}'. Use --help to see available options."
|
||||
)
|
||||
|
||||
def _consume_nargs(
|
||||
self, args: list[str], start: int, spec: Argument
|
||||
) -> tuple[list[str], int]:
|
||||
@ -535,6 +694,7 @@ class CommandArgumentParser:
|
||||
result: dict[str, Any],
|
||||
positional_args: list[Argument],
|
||||
consumed_positional_indicies: set[int],
|
||||
arg_states: dict[str, ArgumentState],
|
||||
from_validate: bool = False,
|
||||
) -> int:
|
||||
remaining_positional_args = [
|
||||
@ -580,17 +740,7 @@ class CommandArgumentParser:
|
||||
except Exception as error:
|
||||
if len(args[i - new_i :]) == 1 and args[i - new_i].startswith("-"):
|
||||
token = args[i - new_i]
|
||||
valid_flags = [
|
||||
flag for flag in self._flag_map if flag.startswith(token)
|
||||
]
|
||||
if valid_flags:
|
||||
raise CommandArgumentError(
|
||||
f"Unrecognized option '{token}'. Did you mean one of: {', '.join(valid_flags)}?"
|
||||
) from error
|
||||
else:
|
||||
raise CommandArgumentError(
|
||||
f"Unrecognized option '{token}'. Use --help to see available options."
|
||||
) from error
|
||||
self._raise_remaining_args_error(token, arg_states)
|
||||
else:
|
||||
raise CommandArgumentError(
|
||||
f"Invalid value for '{spec.dest}': {error}"
|
||||
@ -606,6 +756,8 @@ class CommandArgumentParser:
|
||||
raise CommandArgumentError(
|
||||
f"[{spec.dest}] Action failed: {error}"
|
||||
) from error
|
||||
self._check_if_in_choices(spec, result, arg_states)
|
||||
arg_states[spec.dest].consumed = True
|
||||
elif not typed and spec.default:
|
||||
result[spec.dest] = spec.default
|
||||
elif spec.action == ArgumentAction.APPEND:
|
||||
@ -619,7 +771,11 @@ class CommandArgumentParser:
|
||||
result[spec.dest].extend(typed)
|
||||
elif spec.nargs in (None, 1, "?"):
|
||||
result[spec.dest] = typed[0] if len(typed) == 1 else typed
|
||||
self._check_if_in_choices(spec, result, arg_states)
|
||||
arg_states[spec.dest].consumed = True
|
||||
else:
|
||||
self._check_if_in_choices(spec, result, arg_states)
|
||||
arg_states[spec.dest].consumed = True
|
||||
result[spec.dest] = typed
|
||||
|
||||
if spec.nargs not in ("*", "+"):
|
||||
@ -628,15 +784,7 @@ class CommandArgumentParser:
|
||||
if i < len(args):
|
||||
if len(args[i:]) == 1 and args[i].startswith("-"):
|
||||
token = args[i]
|
||||
valid_flags = [flag for flag in self._flag_map if flag.startswith(token)]
|
||||
if valid_flags:
|
||||
raise CommandArgumentError(
|
||||
f"Unrecognized option '{token}'. Did you mean one of: {', '.join(valid_flags)}?"
|
||||
)
|
||||
else:
|
||||
raise CommandArgumentError(
|
||||
f"Unrecognized option '{token}'. Use --help to see available options."
|
||||
)
|
||||
self._raise_remaining_args_error(token, arg_states)
|
||||
else:
|
||||
plural = "s" if len(args[i:]) > 1 else ""
|
||||
raise CommandArgumentError(
|
||||
@ -670,6 +818,7 @@ class CommandArgumentParser:
|
||||
positional_args: list[Argument],
|
||||
consumed_positional_indices: set[int],
|
||||
consumed_indices: set[int],
|
||||
arg_states: dict[str, ArgumentState],
|
||||
from_validate: bool = False,
|
||||
) -> int:
|
||||
if token in self._keyword:
|
||||
@ -679,6 +828,12 @@ class CommandArgumentParser:
|
||||
if action == ArgumentAction.HELP:
|
||||
if not from_validate:
|
||||
self.render_help()
|
||||
arg_states[spec.dest].consumed = True
|
||||
raise HelpSignal()
|
||||
elif action == ArgumentAction.TLDR:
|
||||
if not from_validate:
|
||||
self.render_tldr()
|
||||
arg_states[spec.dest].consumed = True
|
||||
raise HelpSignal()
|
||||
elif action == ArgumentAction.ACTION:
|
||||
assert isinstance(
|
||||
@ -691,24 +846,30 @@ class CommandArgumentParser:
|
||||
raise CommandArgumentError(
|
||||
f"Invalid value for '{spec.dest}': {error}"
|
||||
) from error
|
||||
if not spec.lazy_resolver or not from_validate:
|
||||
try:
|
||||
result[spec.dest] = await spec.resolver(*typed_values)
|
||||
except Exception as error:
|
||||
raise CommandArgumentError(
|
||||
f"[{spec.dest}] Action failed: {error}"
|
||||
) from error
|
||||
self._check_if_in_choices(spec, result, arg_states)
|
||||
arg_states[spec.dest].consumed = True
|
||||
consumed_indices.update(range(i, new_i))
|
||||
i = new_i
|
||||
elif action == ArgumentAction.STORE_TRUE:
|
||||
result[spec.dest] = True
|
||||
arg_states[spec.dest].consumed = True
|
||||
consumed_indices.add(i)
|
||||
i += 1
|
||||
elif action == ArgumentAction.STORE_FALSE:
|
||||
result[spec.dest] = False
|
||||
arg_states[spec.dest].consumed = True
|
||||
consumed_indices.add(i)
|
||||
i += 1
|
||||
elif action == ArgumentAction.STORE_BOOL_OPTIONAL:
|
||||
result[spec.dest] = spec.type(True)
|
||||
arg_states[spec.dest].consumed = True
|
||||
consumed_indices.add(i)
|
||||
i += 1
|
||||
elif action == ArgumentAction.COUNT:
|
||||
@ -778,19 +939,12 @@ class CommandArgumentParser:
|
||||
)
|
||||
else:
|
||||
result[spec.dest] = typed_values
|
||||
self._check_if_in_choices(spec, result, arg_states)
|
||||
arg_states[spec.dest].consumed = True
|
||||
consumed_indices.update(range(i, new_i))
|
||||
i = new_i
|
||||
elif token.startswith("-"):
|
||||
# Handle unrecognized option
|
||||
valid_flags = [flag for flag in self._flag_map if flag.startswith(token)]
|
||||
if valid_flags:
|
||||
raise CommandArgumentError(
|
||||
f"Unrecognized option '{token}'. Did you mean one of: {', '.join(valid_flags)}?"
|
||||
)
|
||||
else:
|
||||
raise CommandArgumentError(
|
||||
f"Unrecognized option '{token}'. Use --help to see available options."
|
||||
)
|
||||
self._raise_remaining_args_error(token, arg_states)
|
||||
else:
|
||||
# Get the next flagged argument index if it exists
|
||||
next_flagged_index = -1
|
||||
@ -805,6 +959,7 @@ class CommandArgumentParser:
|
||||
result,
|
||||
positional_args,
|
||||
consumed_positional_indices,
|
||||
arg_states=arg_states,
|
||||
from_validate=from_validate,
|
||||
)
|
||||
i += args_consumed
|
||||
@ -813,10 +968,27 @@ class CommandArgumentParser:
|
||||
async def parse_args(
|
||||
self, args: list[str] | None = None, from_validate: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""Parse Falyx Command arguments."""
|
||||
"""
|
||||
Parse arguments into a dictionary of resolved values.
|
||||
|
||||
Args:
|
||||
args (list[str]): The CLI-style argument list.
|
||||
from_validate (bool): If True, enables relaxed resolution for validation mode.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Parsed argument result mapping.
|
||||
"""
|
||||
if args is None:
|
||||
args = []
|
||||
|
||||
arg_states = {arg.dest: ArgumentState(arg) for arg in self._arguments}
|
||||
self._last_positional_states = {
|
||||
arg.dest: arg_states[arg.dest] for arg in self._positional.values()
|
||||
}
|
||||
self._last_keyword_states = {
|
||||
arg.dest: arg_states[arg.dest] for arg in self._keyword_list
|
||||
}
|
||||
|
||||
result = {arg.dest: deepcopy(arg.default) for arg in self._arguments}
|
||||
positional_args: list[Argument] = [
|
||||
arg for arg in self._arguments if arg.positional
|
||||
@ -838,12 +1010,17 @@ class CommandArgumentParser:
|
||||
positional_args,
|
||||
consumed_positional_indices,
|
||||
consumed_indices,
|
||||
arg_states=arg_states,
|
||||
from_validate=from_validate,
|
||||
)
|
||||
|
||||
# Required validation
|
||||
for spec in self._arguments:
|
||||
if spec.dest == "help":
|
||||
if (
|
||||
spec.dest == "help"
|
||||
or spec.dest == "tldr"
|
||||
and spec.action == ArgumentAction.TLDR
|
||||
):
|
||||
continue
|
||||
if spec.required and not result.get(spec.dest):
|
||||
help_text = f" help: {spec.help}" if spec.help else ""
|
||||
@ -853,15 +1030,18 @@ class CommandArgumentParser:
|
||||
and from_validate
|
||||
):
|
||||
if not args:
|
||||
arg_states[spec.dest].consumed = False
|
||||
raise CommandArgumentError(
|
||||
f"Missing required argument '{spec.dest}': {spec.get_choice_text()}{help_text}"
|
||||
)
|
||||
continue # Lazy resolvers are not validated here
|
||||
arg_states[spec.dest].consumed = False
|
||||
raise CommandArgumentError(
|
||||
f"Missing required argument '{spec.dest}': {spec.get_choice_text()}{help_text}"
|
||||
)
|
||||
|
||||
if spec.choices and result.get(spec.dest) not in spec.choices:
|
||||
arg_states[spec.dest].consumed = False
|
||||
raise CommandArgumentError(
|
||||
f"Invalid value for '{spec.dest}': must be one of {{{', '.join(spec.choices)}}}"
|
||||
)
|
||||
@ -878,35 +1058,42 @@ class CommandArgumentParser:
|
||||
if spec.action == ArgumentAction.APPEND:
|
||||
for group in result[spec.dest]:
|
||||
if len(group) % spec.nargs != 0:
|
||||
arg_states[spec.dest].consumed = False
|
||||
raise CommandArgumentError(
|
||||
f"Invalid number of values for '{spec.dest}': expected a multiple of {spec.nargs}"
|
||||
)
|
||||
elif spec.action == ArgumentAction.EXTEND:
|
||||
if len(result[spec.dest]) % spec.nargs != 0:
|
||||
arg_states[spec.dest].consumed = False
|
||||
raise CommandArgumentError(
|
||||
f"Invalid number of values for '{spec.dest}': expected a multiple of {spec.nargs}"
|
||||
)
|
||||
elif len(result[spec.dest]) != spec.nargs:
|
||||
arg_states[spec.dest].consumed = False
|
||||
raise CommandArgumentError(
|
||||
f"Invalid number of values for '{spec.dest}': expected {spec.nargs}, got {len(result[spec.dest])}"
|
||||
)
|
||||
|
||||
result.pop("help", None)
|
||||
result.pop("tldr", None)
|
||||
return result
|
||||
|
||||
async def parse_args_split(
|
||||
self, args: list[str], from_validate: bool = False
|
||||
) -> tuple[tuple[Any, ...], dict[str, Any]]:
|
||||
"""
|
||||
Parse arguments and return both positional and keyword mappings.
|
||||
|
||||
Useful for function-style calling with `*args, **kwargs`.
|
||||
|
||||
Returns:
|
||||
tuple[args, kwargs] - Positional arguments in defined order,
|
||||
followed by keyword argument mapping.
|
||||
tuple: (args tuple, kwargs dict)
|
||||
"""
|
||||
parsed = await self.parse_args(args, from_validate)
|
||||
args_list = []
|
||||
kwargs_dict = {}
|
||||
for arg in self._arguments:
|
||||
if arg.dest == "help":
|
||||
if arg.dest in ("help", "tldr"):
|
||||
continue
|
||||
if arg.positional:
|
||||
args_list.append(parsed[arg.dest])
|
||||
@ -914,7 +1101,137 @@ class CommandArgumentParser:
|
||||
kwargs_dict[arg.dest] = parsed[arg.dest]
|
||||
return tuple(args_list), kwargs_dict
|
||||
|
||||
def suggest_next(
|
||||
self, args: list[str], cursor_at_end_of_token: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Suggest completions for the next argument based on current input.
|
||||
|
||||
This is used for interactive shell completion or prompt_toolkit integration.
|
||||
|
||||
Args:
|
||||
args (list[str]): Current partial argument tokens.
|
||||
cursor_at_end_of_token (bool): True if space at end of args
|
||||
|
||||
Returns:
|
||||
list[str]: List of suggested completions.
|
||||
"""
|
||||
|
||||
# Case 1: Next positional argument
|
||||
next_non_consumed_positional: Argument | None = None
|
||||
for state in self._last_positional_states.values():
|
||||
if not state.consumed:
|
||||
next_non_consumed_positional = state.arg
|
||||
break
|
||||
|
||||
if next_non_consumed_positional:
|
||||
if next_non_consumed_positional.choices:
|
||||
return sorted(
|
||||
(str(choice) for choice in next_non_consumed_positional.choices)
|
||||
)
|
||||
if next_non_consumed_positional.suggestions:
|
||||
return sorted(next_non_consumed_positional.suggestions)
|
||||
|
||||
consumed_dests = [
|
||||
state.arg.dest
|
||||
for state in self._last_keyword_states.values()
|
||||
if state.consumed
|
||||
]
|
||||
|
||||
remaining_flags = [
|
||||
flag for flag, arg in self._keyword.items() if arg.dest not in consumed_dests
|
||||
]
|
||||
|
||||
last_keyword_state_in_args = None
|
||||
for last_arg in reversed(args):
|
||||
if last_arg in self._keyword:
|
||||
last_keyword_state_in_args = self._last_keyword_states.get(
|
||||
self._keyword[last_arg].dest
|
||||
)
|
||||
break
|
||||
|
||||
last = args[-1]
|
||||
next_to_last = args[-2] if len(args) > 1 else ""
|
||||
suggestions: list[str] = []
|
||||
|
||||
# Case 2: Mid-flag (e.g., "--ver")
|
||||
if last.startswith("-") and last not in self._keyword:
|
||||
if last_keyword_state_in_args and not last_keyword_state_in_args.consumed:
|
||||
pass
|
||||
elif (
|
||||
len(args) > 1
|
||||
and next_to_last in self._keyword
|
||||
and next_to_last in remaining_flags
|
||||
):
|
||||
# If the last token is a mid-flag, suggest based on the previous flag
|
||||
arg = self._keyword[next_to_last]
|
||||
if arg.choices:
|
||||
suggestions.extend(arg.choices)
|
||||
elif arg.suggestions:
|
||||
suggestions.extend(arg.suggestions)
|
||||
else:
|
||||
possible_flags = [
|
||||
flag
|
||||
for flag, arg in self._keyword.items()
|
||||
if flag.startswith(last) and arg.dest not in consumed_dests
|
||||
]
|
||||
suggestions.extend(possible_flags)
|
||||
# Case 3: Flag that expects a value (e.g., ["--tag"])
|
||||
elif last in self._keyword:
|
||||
arg = self._keyword[last]
|
||||
if (
|
||||
self._last_keyword_states.get(last.strip("-"))
|
||||
and self._last_keyword_states[last.strip("-")].consumed
|
||||
):
|
||||
pass
|
||||
elif arg.choices:
|
||||
suggestions.extend(arg.choices)
|
||||
elif arg.suggestions:
|
||||
suggestions.extend(arg.suggestions)
|
||||
# Case 4: Last flag with choices mid-choice (e.g., ["--tag", "v"])
|
||||
elif next_to_last in self._keyword:
|
||||
arg = self._keyword[next_to_last]
|
||||
if (
|
||||
self._last_keyword_states.get(next_to_last.strip("-"))
|
||||
and self._last_keyword_states[next_to_last.strip("-")].consumed
|
||||
and last_keyword_state_in_args
|
||||
and Counter(args)[next_to_last]
|
||||
> (
|
||||
last_keyword_state_in_args.arg.nargs
|
||||
if isinstance(last_keyword_state_in_args.arg.nargs, int)
|
||||
else 1
|
||||
)
|
||||
):
|
||||
pass
|
||||
elif arg.choices and last not in arg.choices and not cursor_at_end_of_token:
|
||||
suggestions.extend(arg.choices)
|
||||
elif (
|
||||
arg.suggestions
|
||||
and last not in arg.suggestions
|
||||
and not any(last.startswith(suggestion) for suggestion in arg.suggestions)
|
||||
and any(suggestion.startswith(last) for suggestion in arg.suggestions)
|
||||
and not cursor_at_end_of_token
|
||||
):
|
||||
suggestions.extend(arg.suggestions)
|
||||
elif last_keyword_state_in_args and not last_keyword_state_in_args.consumed:
|
||||
pass
|
||||
else:
|
||||
suggestions.extend(remaining_flags)
|
||||
elif last_keyword_state_in_args and not last_keyword_state_in_args.consumed:
|
||||
pass
|
||||
# Case 5: Suggest all remaining flags
|
||||
else:
|
||||
suggestions.extend(remaining_flags)
|
||||
|
||||
return sorted(set(suggestions))
|
||||
|
||||
def get_options_text(self, plain_text=False) -> str:
|
||||
"""
|
||||
Render all defined arguments as a help-style string.
|
||||
|
||||
Returns:
|
||||
str: A visual description of argument flags and structure.
|
||||
"""
|
||||
# Options
|
||||
# Add all keyword arguments to the options list
|
||||
options_list = []
|
||||
@ -938,6 +1255,14 @@ class CommandArgumentParser:
|
||||
return " ".join(options_list)
|
||||
|
||||
def get_command_keys_text(self, plain_text=False) -> str:
|
||||
"""
|
||||
Return formatted string showing the command key and aliases.
|
||||
|
||||
Used in help rendering and introspection.
|
||||
|
||||
Returns:
|
||||
str: The visual command selector line.
|
||||
"""
|
||||
if plain_text:
|
||||
command_keys = " | ".join(
|
||||
[f"{self.command_key}"] + [f"{alias}" for alias in self.aliases]
|
||||
@ -953,7 +1278,12 @@ class CommandArgumentParser:
|
||||
return command_keys
|
||||
|
||||
def get_usage(self, plain_text=False) -> str:
|
||||
"""Get the usage text for the command."""
|
||||
"""
|
||||
Render the usage string for this parser.
|
||||
|
||||
Returns:
|
||||
str: A formatted usage line showing syntax and argument structure.
|
||||
"""
|
||||
command_keys = self.get_command_keys_text(plain_text)
|
||||
options_text = self.get_options_text(plain_text)
|
||||
if options_text:
|
||||
@ -961,6 +1291,11 @@ class CommandArgumentParser:
|
||||
return command_keys
|
||||
|
||||
def render_help(self) -> None:
|
||||
"""
|
||||
Print formatted help text for this command using Rich output.
|
||||
|
||||
Includes usage, description, argument groups, and optional epilog.
|
||||
"""
|
||||
usage = self.get_usage()
|
||||
self.console.print(f"[bold]usage: {usage}[/bold]\n")
|
||||
|
||||
@ -1010,6 +1345,48 @@ class CommandArgumentParser:
|
||||
if self.help_epilog:
|
||||
self.console.print("\n" + self.help_epilog, style="dim")
|
||||
|
||||
def render_tldr(self) -> None:
|
||||
"""
|
||||
Print TLDR examples for this command using Rich output.
|
||||
|
||||
Displays brief usage examples with descriptions.
|
||||
"""
|
||||
if not self._tldr_examples:
|
||||
self.console.print("[bold]No TLDR examples available.[/bold]")
|
||||
return
|
||||
is_cli_mode = self.options_manager.get("mode") in {
|
||||
FalyxMode.RUN,
|
||||
FalyxMode.PREVIEW,
|
||||
FalyxMode.RUN_ALL,
|
||||
}
|
||||
|
||||
program = self.program or "falyx"
|
||||
command = self.aliases[0] if self.aliases else self.command_key
|
||||
if is_cli_mode:
|
||||
command = (
|
||||
f"[{self.command_style}]{program} run {command}[/{self.command_style}]"
|
||||
)
|
||||
else:
|
||||
command = f"[{self.command_style}]{command}[/{self.command_style}]"
|
||||
|
||||
usage = self.get_usage()
|
||||
self.console.print(f"[bold]usage:[/] {usage}\n")
|
||||
|
||||
if self.help_text:
|
||||
self.console.print(f"{self.help_text}\n")
|
||||
|
||||
self.console.print("[bold]examples:[/bold]")
|
||||
for example in self._tldr_examples:
|
||||
usage = f"{command} {example.usage.strip()}"
|
||||
description = example.description.strip()
|
||||
block = f"[bold]{usage}[/bold]"
|
||||
self.console.print(
|
||||
Padding(
|
||||
Panel(block, expand=False, title=description, title_align="left"),
|
||||
(0, 2),
|
||||
)
|
||||
)
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, CommandArgumentParser):
|
||||
return False
|
||||
@ -1023,6 +1400,7 @@ class CommandArgumentParser:
|
||||
return hash(tuple(sorted(self._arguments, key=lambda a: a.dest)))
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return a human-readable summary of the parser state."""
|
||||
positional = sum(arg.positional for arg in self._arguments)
|
||||
required = sum(arg.required for arg in self._arguments)
|
||||
return (
|
||||
|
@ -1,15 +1,52 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""parser_types.py"""
|
||||
"""
|
||||
Type utilities and argument state models for Falyx's custom CLI argument parser.
|
||||
|
||||
This module provides specialized helpers and data structures used by
|
||||
the `CommandArgumentParser` to handle non-standard parsing behavior.
|
||||
|
||||
Contents:
|
||||
- `true_none` / `false_none`: Type coercion utilities that allow tri-state boolean
|
||||
semantics (True, False, None). These are especially useful for supporting
|
||||
`--flag` / `--no-flag` optional booleans in CLI arguments.
|
||||
- `ArgumentState`: Tracks whether an `Argument` has been consumed during parsing.
|
||||
- `TLDRExample`: A structured example for showing usage snippets and descriptions,
|
||||
used in TLDR views.
|
||||
|
||||
These tools support richer expressiveness and user-friendly ergonomics in
|
||||
Falyx's declarative command-line interfaces.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from falyx.parser.argument import Argument
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArgumentState:
|
||||
"""Tracks an argument and whether it has been consumed."""
|
||||
|
||||
arg: Argument
|
||||
consumed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TLDRExample:
|
||||
"""Represents a usage example for TLDR output."""
|
||||
|
||||
usage: str
|
||||
description: str
|
||||
|
||||
|
||||
def true_none(value: Any) -> bool | None:
|
||||
"""Return True if value is not None, else None."""
|
||||
if value is None:
|
||||
return None
|
||||
return True
|
||||
|
||||
|
||||
def false_none(value: Any) -> bool | None:
|
||||
"""Return False if value is not None, else None."""
|
||||
if value is None:
|
||||
return None
|
||||
return False
|
||||
|
@ -1,7 +1,22 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""parsers.py
|
||||
This module contains the argument parsers used for the Falyx CLI.
|
||||
"""
|
||||
Provides the argument parser infrastructure for the Falyx CLI.
|
||||
|
||||
This module defines the `FalyxParsers` dataclass and related utilities for building
|
||||
structured CLI interfaces with argparse. It supports top-level CLI commands like
|
||||
`run`, `run-all`, `preview`, `list`, and `version`, and integrates seamlessly with
|
||||
registered `Command` objects for dynamic help, usage generation, and argument handling.
|
||||
|
||||
Key Components:
|
||||
- `FalyxParsers`: Container for all CLI subparsers.
|
||||
- `get_arg_parsers()`: Factory for generating full parser suite.
|
||||
- `get_root_parser()`: Creates the root-level CLI parser with global options.
|
||||
- `get_subparsers()`: Helper to attach subcommand parsers to the root parser.
|
||||
|
||||
Used internally by the Falyx CLI `run()` entry point to parse arguments and route
|
||||
execution across commands and workflows.
|
||||
"""
|
||||
|
||||
from argparse import (
|
||||
REMAINDER,
|
||||
ArgumentParser,
|
||||
@ -44,9 +59,7 @@ def get_root_parser(
|
||||
prog: str | None = "falyx",
|
||||
usage: str | None = None,
|
||||
description: str | None = "Falyx CLI - Run structured async command workflows.",
|
||||
epilog: (
|
||||
str | None
|
||||
) = "Tip: Use 'falyx run ?[COMMAND]' to preview any command from the CLI.",
|
||||
epilog: str | None = "Tip: Use 'falyx run ?' to show available commands.",
|
||||
parents: Sequence[ArgumentParser] | None = None,
|
||||
prefix_chars: str = "-",
|
||||
fromfile_prefix_chars: str | None = None,
|
||||
@ -227,7 +240,7 @@ def get_arg_parsers(
|
||||
- Use `falyx run ?[COMMAND]` from the CLI to preview a command.
|
||||
"""
|
||||
if epilog is None:
|
||||
epilog = f"Tip: Use '{prog} run ?[COMMAND]' to preview any command from the CLI."
|
||||
epilog = f"Tip: Use '{prog} run ?' to show available commands."
|
||||
if root_parser is None:
|
||||
parser = get_root_parser(
|
||||
prog=prog,
|
||||
|
@ -1,4 +1,15 @@
|
||||
# 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
|
||||
|
||||
@ -10,8 +21,18 @@ def infer_args_from_func(
|
||||
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.
|
||||
Infer CLI-style argument definitions from a function signature.
|
||||
|
||||
This utility inspects the parameters of a function and returns a list of dictionaries,
|
||||
each of which can be passed to `CommandArgumentParser.add_argument()`.
|
||||
|
||||
Args:
|
||||
func (Callable | None): The function to inspect.
|
||||
arg_metadata (dict | None): Optional metadata overrides for help text, type hints,
|
||||
choices, and suggestions for each parameter.
|
||||
|
||||
Returns:
|
||||
list[dict[str, Any]]: A list of argument definitions inferred from the function.
|
||||
"""
|
||||
if not callable(func):
|
||||
logger.debug("Provided argument is not callable: %s", func)
|
||||
@ -54,8 +75,10 @@ def infer_args_from_func(
|
||||
if arg_type is bool:
|
||||
if param.default is False:
|
||||
action = "store_true"
|
||||
else:
|
||||
default = None
|
||||
elif param.default is True:
|
||||
action = "store_false"
|
||||
default = None
|
||||
|
||||
if arg_type is list:
|
||||
action = "append"
|
||||
@ -75,6 +98,7 @@ def infer_args_from_func(
|
||||
"action": action,
|
||||
"help": metadata.get("help", ""),
|
||||
"choices": metadata.get("choices"),
|
||||
"suggestions": metadata.get("suggestions"),
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -1,4 +1,17 @@
|
||||
# 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
|
||||
@ -12,6 +25,17 @@ from falyx.parser.signature import infer_args_from_func
|
||||
|
||||
|
||||
def coerce_bool(value: str) -> bool:
|
||||
"""
|
||||
Convert a string to a boolean.
|
||||
|
||||
Accepts various truthy and falsy representations such as 'true', 'yes', '0', 'off', etc.
|
||||
|
||||
Args:
|
||||
value (str): The input string or boolean.
|
||||
|
||||
Returns:
|
||||
bool: Parsed boolean result.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
value = value.strip().lower()
|
||||
@ -23,6 +47,21 @@ def coerce_bool(value: str) -> bool:
|
||||
|
||||
|
||||
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
|
||||
|
||||
@ -42,6 +81,21 @@ def coerce_enum(value: Any, enum_type: EnumMeta) -> Any:
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@ -79,7 +133,19 @@ 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):
|
||||
|
@ -1,11 +1,24 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""prompt_utils.py"""
|
||||
"""
|
||||
Utilities for user interaction prompts in the Falyx CLI framework.
|
||||
|
||||
Provides asynchronous confirmation dialogs and helper logic to determine
|
||||
whether a user should be prompted based on command-line options.
|
||||
|
||||
Includes:
|
||||
- `should_prompt_user()` for conditional prompt logic.
|
||||
- `confirm_async()` for interactive yes/no confirmation.
|
||||
"""
|
||||
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.formatted_text import (
|
||||
AnyFormattedText,
|
||||
FormattedText,
|
||||
StyleAndTextTuples,
|
||||
merge_formatted_text,
|
||||
)
|
||||
from rich.console import Console
|
||||
from rich.text import Text
|
||||
|
||||
from falyx.options_manager import OptionsManager
|
||||
from falyx.themes import OneColors
|
||||
@ -46,3 +59,31 @@ async def confirm_async(
|
||||
validator=yes_no_validator(),
|
||||
)
|
||||
return answer.upper() == "Y"
|
||||
|
||||
|
||||
def rich_text_to_prompt_text(text: Text | str | StyleAndTextTuples) -> StyleAndTextTuples:
|
||||
"""
|
||||
Convert a Rich Text object to a list of (style, text) tuples
|
||||
compatible with prompt_toolkit.
|
||||
"""
|
||||
if isinstance(text, list):
|
||||
if all(isinstance(pair, tuple) and len(pair) == 2 for pair in text):
|
||||
return text
|
||||
raise TypeError("Expected list of (style, text) tuples")
|
||||
|
||||
if isinstance(text, str):
|
||||
text = Text.from_markup(text)
|
||||
|
||||
if not isinstance(text, Text):
|
||||
raise TypeError("Expected str, rich.text.Text, or list of (style, text) tuples")
|
||||
|
||||
console = Console(color_system=None, file=None, width=999, legacy_windows=False)
|
||||
segments = text.render(console)
|
||||
|
||||
prompt_fragments: StyleAndTextTuples = []
|
||||
for segment in segments:
|
||||
style = segment.style or ""
|
||||
string = segment.text
|
||||
if string:
|
||||
prompt_fragments.append((str(style), string))
|
||||
return prompt_fragments
|
||||
|
@ -1,5 +1,18 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""protocols.py"""
|
||||
"""
|
||||
Defines structural protocols for advanced Falyx features.
|
||||
|
||||
These runtime-checkable `Protocol` classes specify the expected interfaces for:
|
||||
- Factories that asynchronously return actions
|
||||
- Argument parsers used in dynamic command execution
|
||||
|
||||
Used to support type-safe extensibility and plugin-like behavior without requiring
|
||||
explicit base classes.
|
||||
|
||||
Protocols:
|
||||
- ActionFactoryProtocol: Async callable that returns a coroutine yielding a BaseAction.
|
||||
- ArgParserProtocol: Callable that accepts CLI-style args and returns (args, kwargs) tuple.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Awaitable, Callable, Protocol, runtime_checkable
|
||||
|
@ -1,5 +1,23 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""retry.py"""
|
||||
"""
|
||||
Implements retry logic for Falyx Actions using configurable retry policies.
|
||||
|
||||
This module defines:
|
||||
- `RetryPolicy`: A configurable model controlling retry behavior (delay, backoff, jitter).
|
||||
- `RetryHandler`: A hook-compatible class that manages retry attempts for failed actions.
|
||||
|
||||
Used to automatically retry transient failures in leaf-level `Action` objects
|
||||
when marked as retryable. Integrates with the Falyx hook lifecycle via `on_error`.
|
||||
|
||||
Supports:
|
||||
- Exponential backoff with optional jitter
|
||||
- Manual or declarative policy control
|
||||
- Per-action retry logging and recovery
|
||||
|
||||
Example:
|
||||
handler = RetryHandler(RetryPolicy(max_retries=5, delay=1.0))
|
||||
action.hooks.register(HookType.ON_ERROR, handler.retry_on_error)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
@ -12,7 +30,28 @@ from falyx.logger import logger
|
||||
|
||||
|
||||
class RetryPolicy(BaseModel):
|
||||
"""RetryPolicy"""
|
||||
"""
|
||||
Defines a retry strategy for Falyx `Action` objects.
|
||||
|
||||
This model controls whether an action should be retried on failure, and how:
|
||||
- `max_retries`: Maximum number of retry attempts.
|
||||
- `delay`: Initial wait time before the first retry (in seconds).
|
||||
- `backoff`: Multiplier applied to the delay after each failure (≥ 1.0).
|
||||
- `jitter`: Optional random noise added/subtracted from delay to reduce thundering herd issues.
|
||||
- `enabled`: Whether this policy is currently active.
|
||||
|
||||
Retry is only triggered for leaf-level `Action` instances marked with `is_retryable=True`
|
||||
and registered with an appropriate `RetryHandler`.
|
||||
|
||||
Example:
|
||||
RetryPolicy(max_retries=3, delay=1.0, backoff=2.0, jitter=0.2, enabled=True)
|
||||
|
||||
Use `enable_policy()` to activate the policy after construction.
|
||||
|
||||
See Also:
|
||||
- `RetryHandler`: Executes retry logic based on this configuration.
|
||||
- `HookType.ON_ERROR`: The hook type used to trigger retries.
|
||||
"""
|
||||
|
||||
max_retries: int = Field(default=3, ge=0)
|
||||
delay: float = Field(default=1.0, ge=0.0)
|
||||
@ -36,7 +75,27 @@ class RetryPolicy(BaseModel):
|
||||
|
||||
|
||||
class RetryHandler:
|
||||
"""RetryHandler class to manage retry policies for actions."""
|
||||
"""
|
||||
Executes retry logic for Falyx actions using a provided `RetryPolicy`.
|
||||
|
||||
This class is intended to be registered as an `on_error` hook. It will
|
||||
re-attempt the failed `Action`'s `action` method using the args/kwargs from
|
||||
the failed context, following exponential backoff and optional jitter.
|
||||
|
||||
Only supports retrying leaf `Action` instances (not ChainedAction or ActionGroup)
|
||||
where `is_retryable=True`.
|
||||
|
||||
Attributes:
|
||||
policy (RetryPolicy): The retry configuration controlling timing and limits.
|
||||
|
||||
Example:
|
||||
handler = RetryHandler(RetryPolicy(max_retries=3, delay=1.0, enabled=True))
|
||||
action.hooks.register(HookType.ON_ERROR, handler.retry_on_error)
|
||||
|
||||
Notes:
|
||||
- Retries are not triggered if the policy is disabled or `max_retries=0`.
|
||||
- All retry attempts and final failure are logged automatically.
|
||||
"""
|
||||
|
||||
def __init__(self, policy: RetryPolicy = RetryPolicy()):
|
||||
self.policy = policy
|
||||
@ -90,14 +149,18 @@ class RetryHandler:
|
||||
sleep_delay = current_delay
|
||||
if self.policy.jitter > 0:
|
||||
sleep_delay += random.uniform(-self.policy.jitter, self.policy.jitter)
|
||||
|
||||
logger.debug(
|
||||
"[%s] Error: %s",
|
||||
name,
|
||||
last_error,
|
||||
)
|
||||
logger.info(
|
||||
"[%s] Retrying (%s/%s) in %ss due to '%s'...",
|
||||
name,
|
||||
retries_done,
|
||||
self.policy.max_retries,
|
||||
current_delay,
|
||||
last_error,
|
||||
last_error.__class__.__name__,
|
||||
)
|
||||
await asyncio.sleep(current_delay)
|
||||
try:
|
||||
@ -109,12 +172,17 @@ class RetryHandler:
|
||||
except Exception as retry_error:
|
||||
last_error = retry_error
|
||||
current_delay *= self.policy.backoff
|
||||
logger.debug(
|
||||
"[%s] Error: %s",
|
||||
name,
|
||||
retry_error,
|
||||
)
|
||||
logger.warning(
|
||||
"[%s] Retry attempt %s/%s failed due to '%s'.",
|
||||
name,
|
||||
retries_done,
|
||||
self.policy.max_retries,
|
||||
retry_error,
|
||||
retry_error.__class__.__name__,
|
||||
)
|
||||
|
||||
context.exception = last_error
|
||||
|
@ -1,5 +1,14 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""retry_utils.py"""
|
||||
"""
|
||||
Utilities for enabling retry behavior across Falyx actions.
|
||||
|
||||
This module provides a helper to recursively apply a `RetryPolicy` to an action and its
|
||||
nested children (e.g. `ChainedAction`, `ActionGroup`), and register the appropriate
|
||||
`RetryHandler` to hook into error handling.
|
||||
|
||||
Includes:
|
||||
- `enable_retries_recursively`: Attaches a retry policy and error hook to all eligible actions.
|
||||
"""
|
||||
from falyx.action.action import Action
|
||||
from falyx.action.base_action import BaseAction
|
||||
from falyx.hook_manager import HookType
|
||||
|
@ -1,5 +1,17 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""selection.py"""
|
||||
"""
|
||||
Provides interactive selection utilities for Falyx CLI actions.
|
||||
|
||||
This module defines `SelectionOption` objects, selection maps, and rich-powered
|
||||
rendering functions to build interactive selection prompts using `prompt_toolkit`.
|
||||
It supports:
|
||||
- Grid-based and dictionary-based selection menus
|
||||
- Index- or key-driven multi-select prompts
|
||||
- Formatted Rich tables for CLI visual menus
|
||||
- Cancel keys, defaults, and duplication control
|
||||
|
||||
Used by `SelectionAction` and other prompt-driven workflows within Falyx.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, KeysView, Sequence
|
||||
|
||||
@ -9,6 +21,7 @@ from rich.markup import escape
|
||||
from rich.table import Table
|
||||
|
||||
from falyx.console import console
|
||||
from falyx.prompt_utils import rich_text_to_prompt_text
|
||||
from falyx.themes import OneColors
|
||||
from falyx.utils import CaseInsensitiveDict, chunks
|
||||
from falyx.validators import MultiIndexValidator, MultiKeyValidator
|
||||
@ -281,7 +294,7 @@ async def prompt_for_index(
|
||||
console.print(table, justify="center")
|
||||
|
||||
selection = await prompt_session.prompt_async(
|
||||
message=prompt_message,
|
||||
message=rich_text_to_prompt_text(prompt_message),
|
||||
validator=MultiIndexValidator(
|
||||
min_index,
|
||||
max_index,
|
||||
@ -320,7 +333,7 @@ async def prompt_for_selection(
|
||||
console.print(table, justify="center")
|
||||
|
||||
selected = await prompt_session.prompt_async(
|
||||
message=prompt_message,
|
||||
message=rich_text_to_prompt_text(prompt_message),
|
||||
validator=MultiKeyValidator(
|
||||
keys, number_selections, separator, allow_duplicates, cancel_key
|
||||
),
|
||||
|
@ -1,5 +1,21 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""signals.py"""
|
||||
"""
|
||||
Defines flow control signals used internally by the Falyx CLI framework.
|
||||
|
||||
These signals are raised to interrupt or redirect CLI execution flow
|
||||
(e.g., returning to a menu, quitting, or displaying help) without
|
||||
being treated as traditional exceptions.
|
||||
|
||||
All signals inherit from `FlowSignal`, which is a subclass of `BaseException`
|
||||
to ensure they bypass standard `except Exception` blocks.
|
||||
|
||||
Signals:
|
||||
- BreakChainSignal: Exit a chained action early.
|
||||
- QuitSignal: Terminate the CLI session.
|
||||
- BackSignal: Return to the previous menu or caller.
|
||||
- CancelSignal: Cancel the current operation.
|
||||
- HelpSignal: Trigger help output in interactive flows.
|
||||
"""
|
||||
|
||||
|
||||
class FlowSignal(BaseException):
|
||||
|
248
falyx/spinner_manager.py
Normal file
248
falyx/spinner_manager.py
Normal file
@ -0,0 +1,248 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""
|
||||
Centralized spinner rendering for Falyx CLI.
|
||||
|
||||
This module provides the `SpinnerManager` class, which manages a collection of
|
||||
Rich spinners that can be displayed concurrently during long-running tasks.
|
||||
|
||||
Key Features:
|
||||
• Automatic lifecycle management:
|
||||
- Starts a single Rich `Live` loop when the first spinner is added.
|
||||
- Stops and clears the display when the last spinner is removed.
|
||||
• Thread/async-safe start logic via a lightweight lock to prevent
|
||||
duplicate Live loops from being launched.
|
||||
• Supports multiple spinners running simultaneously, each with its own
|
||||
text, style, type, and speed.
|
||||
• Integrates with Falyx's OptionsManager so actions and commands can
|
||||
declaratively request spinners without directly managing terminal state.
|
||||
|
||||
Classes:
|
||||
SpinnerData:
|
||||
Lightweight container for individual spinner settings (message,
|
||||
type, style, speed) and its underlying Rich `Spinner` object.
|
||||
SpinnerManager:
|
||||
Manages all active spinners, handles Live rendering, and provides
|
||||
methods to add, update, and remove spinners.
|
||||
|
||||
Example:
|
||||
```python
|
||||
>>> manager = SpinnerManager()
|
||||
>>> await manager.add("build", "Building project…", spinner_type="dots")
|
||||
>>> await manager.add("deploy", "Deploying to AWS…", spinner_type="earth")
|
||||
# Both spinners animate in one unified Live panel
|
||||
>>> manager.remove("build")
|
||||
>>> manager.remove("deploy")
|
||||
```
|
||||
|
||||
Design Notes:
|
||||
• SpinnerManager should only create **one** Live loop at a time.
|
||||
• When no spinners remain, the Live panel is cleared (`transient=True`)
|
||||
so the CLI output returns to a clean state.
|
||||
• Hooks in `falyx.hooks` (spinner_before_hook / spinner_teardown_hook)
|
||||
call into this manager automatically when `spinner=True` is set on
|
||||
an Action or Command.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from rich.console import Group
|
||||
from rich.live import Live
|
||||
from rich.spinner import Spinner
|
||||
|
||||
from falyx.console import console
|
||||
from falyx.logger import logger
|
||||
from falyx.themes import OneColors
|
||||
|
||||
|
||||
class SpinnerData:
|
||||
"""
|
||||
Holds the configuration and Rich spinner object for a single task.
|
||||
|
||||
This class is a lightweight container for spinner metadata, storing the
|
||||
message text, spinner type, style, and speed. It also initializes the
|
||||
corresponding Rich `Spinner` instance used by `SpinnerManager` for
|
||||
rendering.
|
||||
|
||||
Attributes:
|
||||
text (str): The message displayed next to the spinner.
|
||||
spinner_type (str): The Rich spinner preset to use (e.g., "dots",
|
||||
"bouncingBall", "earth").
|
||||
spinner_style (str): Rich color/style for the spinner animation.
|
||||
spinner (Spinner): The instantiated Rich spinner object.
|
||||
|
||||
Example:
|
||||
```
|
||||
>>> data = SpinnerData("Deploying...", spinner_type="earth",
|
||||
... spinner_style="cyan", spinner_speed=1.0)
|
||||
>>> data.spinner
|
||||
<rich.spinner.Spinner object ...>
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, text: str, spinner_type: str, spinner_style: str, spinner_speed: float
|
||||
):
|
||||
"""Initialize a spinner with text, type, style, and speed."""
|
||||
self.text = text
|
||||
self.spinner_type = spinner_type
|
||||
self.spinner_style = spinner_style
|
||||
self.spinner = Spinner(
|
||||
spinner_type, text=text, style=spinner_style, speed=spinner_speed
|
||||
)
|
||||
|
||||
|
||||
class SpinnerManager:
|
||||
"""
|
||||
Manages multiple Rich spinners and handles their terminal rendering.
|
||||
|
||||
SpinnerManager maintains a registry of active spinners and a single
|
||||
Rich `Live` display loop to render them. When the first spinner is added,
|
||||
the Live loop starts automatically. When the last spinner is removed,
|
||||
the Live loop stops and the panel clears (via `transient=True`).
|
||||
|
||||
This class is designed for integration with Falyx's `OptionsManager`
|
||||
so any Action or Command can declaratively register spinners without
|
||||
directly controlling terminal state.
|
||||
|
||||
Key Behaviors:
|
||||
• Starts exactly one `Live` loop, protected by a start lock to prevent
|
||||
duplicate launches in async/threaded contexts.
|
||||
• Supports multiple simultaneous spinners, each with independent
|
||||
text, style, and type.
|
||||
• Clears the display when all spinners are removed.
|
||||
|
||||
Attributes:
|
||||
console (Console): The Rich console used for rendering.
|
||||
_spinners (dict[str, SpinnerData]): Internal store of active spinners.
|
||||
_task (asyncio.Task | None): The running Live loop task, if any.
|
||||
_running (bool): Indicates if the Live loop is currently active.
|
||||
|
||||
Example:
|
||||
```
|
||||
>>> manager = SpinnerManager()
|
||||
>>> await manager.add("build", "Building project…")
|
||||
>>> await manager.add("deploy", "Deploying services…", spinner_type="earth")
|
||||
>>> manager.remove("build")
|
||||
>>> manager.remove("deploy")
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the SpinnerManager with an empty spinner registry."""
|
||||
self.console = console
|
||||
self._spinners: dict[str, SpinnerData] = {}
|
||||
self._task: asyncio.Task | None = None
|
||||
self._running: bool = False
|
||||
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def add(
|
||||
self,
|
||||
name: str,
|
||||
text: str,
|
||||
spinner_type: str = "dots",
|
||||
spinner_style: str = OneColors.CYAN,
|
||||
spinner_speed: float = 1.0,
|
||||
):
|
||||
"""Add a new spinner and start the Live loop if not already running."""
|
||||
self._spinners[name] = SpinnerData(
|
||||
text=text,
|
||||
spinner_type=spinner_type,
|
||||
spinner_style=spinner_style,
|
||||
spinner_speed=spinner_speed,
|
||||
)
|
||||
async with self._lock:
|
||||
if not self._running:
|
||||
logger.debug("[%s] Starting spinner manager Live loop.", name)
|
||||
await self._start_live()
|
||||
|
||||
def update(
|
||||
self,
|
||||
name: str,
|
||||
text: str | None = None,
|
||||
spinner_type: str | None = None,
|
||||
spinner_style: str | None = None,
|
||||
):
|
||||
"""Update an existing spinner's message, style, or type."""
|
||||
if name in self._spinners:
|
||||
data = self._spinners[name]
|
||||
if text:
|
||||
data.text = text
|
||||
data.spinner.text = text
|
||||
if spinner_style:
|
||||
data.spinner_style = spinner_style
|
||||
data.spinner.style = spinner_style
|
||||
if spinner_type:
|
||||
data.spinner_type = spinner_type
|
||||
data.spinner = Spinner(spinner_type, text=data.text)
|
||||
|
||||
async def remove(self, name: str):
|
||||
"""Remove a spinner and stop the Live loop if no spinners remain."""
|
||||
self._spinners.pop(name, None)
|
||||
async with self._lock:
|
||||
if not self._spinners:
|
||||
logger.debug("[%s] Stopping spinner manager, no spinners left.", name)
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
self._running = False
|
||||
|
||||
async def _start_live(self):
|
||||
"""Start the Live rendering loop in the background."""
|
||||
self._running = True
|
||||
self._task = asyncio.create_task(self._live_loop())
|
||||
|
||||
def render_panel(self):
|
||||
"""Render all active spinners as a grouped Rich panel."""
|
||||
rows = []
|
||||
for data in self._spinners.values():
|
||||
rows.append(data.spinner)
|
||||
return Group(*rows)
|
||||
|
||||
async def _live_loop(self):
|
||||
"""Continuously refresh the spinner display until stopped."""
|
||||
with Live(
|
||||
self.render_panel(),
|
||||
refresh_per_second=12.5,
|
||||
console=self.console,
|
||||
transient=True,
|
||||
) as live:
|
||||
while self._spinners:
|
||||
live.update(self.render_panel())
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
spinner_manager = SpinnerManager()
|
||||
|
||||
async def demo():
|
||||
# Add multiple spinners
|
||||
await spinner_manager.add("task1", "Loading configs…")
|
||||
await spinner_manager.add(
|
||||
"task2", "Building containers…", spinner_type="bouncingBall"
|
||||
)
|
||||
await spinner_manager.add("task3", "Deploying services…", spinner_type="earth")
|
||||
|
||||
# Simulate work
|
||||
await asyncio.sleep(2)
|
||||
spinner_manager.update("task1", text="Configs loaded ✅")
|
||||
await asyncio.sleep(1)
|
||||
spinner_manager.remove("task1")
|
||||
|
||||
await spinner_manager.add("task4", "Running Tests...")
|
||||
|
||||
await asyncio.sleep(2)
|
||||
spinner_manager.update("task2", text="Build complete ✅")
|
||||
spinner_manager.remove("task2")
|
||||
|
||||
await asyncio.sleep(1)
|
||||
spinner_manager.update("task3", text="Deployed! 🎉")
|
||||
await asyncio.sleep(1)
|
||||
spinner_manager.remove("task3")
|
||||
|
||||
await asyncio.sleep(5)
|
||||
|
||||
spinner_manager.update("task4", "Tests Complete!")
|
||||
spinner_manager.remove("task4")
|
||||
console.print("Done!")
|
||||
|
||||
asyncio.run(demo())
|
@ -1,5 +1,15 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""tagged_table.py"""
|
||||
"""
|
||||
Generates a Rich table view of Falyx commands grouped by their tags.
|
||||
|
||||
This module defines a utility function for rendering a custom CLI command
|
||||
table that organizes commands into groups based on their first tag. It is
|
||||
used to visually separate commands in interactive menus for better clarity
|
||||
and discoverability.
|
||||
|
||||
Functions:
|
||||
- build_tagged_table(flx): Returns a `rich.Table` of commands grouped by tag.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
|
||||
from rich import box
|
||||
|
@ -1,6 +1,5 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""
|
||||
colors.py
|
||||
|
||||
A Python module that integrates the Nord color palette with the Rich library.
|
||||
It defines a metaclass-based NordColors class allowing dynamic attribute lookups
|
||||
(e.g., NORD12bu -> "#D08770 bold underline") and provides a comprehensive Nord-based
|
||||
|
@ -1,5 +1,21 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""utils.py"""
|
||||
"""
|
||||
General-purpose utilities and helpers for the Falyx CLI framework.
|
||||
|
||||
This module includes asynchronous wrappers, logging setup, formatting utilities,
|
||||
and small type-safe enhancements such as `CaseInsensitiveDict` and coroutine enforcement.
|
||||
|
||||
Features:
|
||||
- `ensure_async`: Wraps sync functions as async coroutines.
|
||||
- `chunks`: Splits an iterable into fixed-size chunks.
|
||||
- `CaseInsensitiveDict`: Dict subclass with case-insensitive string keys.
|
||||
- `setup_logging`: Configures Rich or JSON logging based on environment or container detection.
|
||||
- `get_program_invocation`: Returns the recommended CLI command to invoke the program.
|
||||
- `running_in_container`: Detects if the process is running inside a container.
|
||||
|
||||
These utilities support consistent behavior across CLI rendering, logging,
|
||||
command parsing, and compatibility layers.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
@ -14,6 +30,8 @@ from typing import Any, Awaitable, Callable, TypeVar
|
||||
import pythonjsonlogger.json
|
||||
from rich.logging import RichHandler
|
||||
|
||||
from falyx.console import console
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@ -164,6 +182,7 @@ def setup_logging(
|
||||
|
||||
if mode == "cli":
|
||||
console_handler: RichHandler | logging.StreamHandler = RichHandler(
|
||||
console=console,
|
||||
rich_tracebacks=True,
|
||||
show_time=True,
|
||||
show_level=True,
|
||||
|
@ -1,9 +1,62 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""validators.py"""
|
||||
from typing import KeysView, Sequence
|
||||
"""
|
||||
Input validators for use with Prompt Toolkit and interactive Falyx CLI workflows.
|
||||
|
||||
This module defines reusable `Validator` instances and subclasses that enforce valid
|
||||
user input during prompts—especially for selection actions, confirmations, and
|
||||
argument parsing.
|
||||
|
||||
Included Validators:
|
||||
- CommandValidator: Validates if the input matches a known command.
|
||||
- int_range_validator: Enforces numeric input within a range.
|
||||
- key_validator: Ensures the entered value matches a valid selection key.
|
||||
- yes_no_validator: Restricts input to 'Y' or 'N'.
|
||||
- word_validator / words_validator: Accepts specific valid words (case-insensitive).
|
||||
- MultiIndexValidator: Validates numeric list input (e.g. "1,2,3").
|
||||
- MultiKeyValidator: Validates string key list input (e.g. "A,B,C").
|
||||
|
||||
These validators integrate directly into `PromptSession.prompt_async()` to
|
||||
enforce correctness and provide helpful error messages.
|
||||
"""
|
||||
from typing import TYPE_CHECKING, KeysView, Sequence
|
||||
|
||||
from prompt_toolkit.validation import ValidationError, Validator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from falyx.falyx import Falyx
|
||||
|
||||
|
||||
class CommandValidator(Validator):
|
||||
"""Validator to check if the input is a valid command."""
|
||||
|
||||
def __init__(self, falyx: "Falyx", error_message: str) -> None:
|
||||
super().__init__()
|
||||
self.falyx = falyx
|
||||
self.error_message = error_message
|
||||
|
||||
def validate(self, document) -> None:
|
||||
if not document.text:
|
||||
raise ValidationError(
|
||||
message=self.error_message,
|
||||
cursor_position=len(document.text),
|
||||
)
|
||||
|
||||
async def validate_async(self, document) -> None:
|
||||
text = document.text
|
||||
if not text:
|
||||
raise ValidationError(
|
||||
message=self.error_message,
|
||||
cursor_position=len(text),
|
||||
)
|
||||
is_preview, choice, _, __ = await self.falyx.get_command(text, from_validate=True)
|
||||
if is_preview:
|
||||
return None
|
||||
if not choice:
|
||||
raise ValidationError(
|
||||
message=self.error_message,
|
||||
cursor_position=len(text),
|
||||
)
|
||||
|
||||
|
||||
def int_range_validator(minimum: int, maximum: int) -> Validator:
|
||||
"""Validator for integer ranges."""
|
||||
|
@ -1 +1 @@
|
||||
__version__ = "0.1.62"
|
||||
__version__ = "0.1.78"
|
||||
|
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "falyx"
|
||||
version = "0.1.62"
|
||||
version = "0.1.78"
|
||||
description = "Reliable and introspectable async CLI action framework."
|
||||
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
|
||||
license = "MIT"
|
||||
@ -10,7 +10,7 @@ packages = [{ include = "falyx" }]
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.10"
|
||||
prompt_toolkit = "^3.0"
|
||||
rich = "^13.0"
|
||||
rich = "^14.0"
|
||||
pydantic = "^2.0"
|
||||
python-json-logger = "^3.3.0"
|
||||
toml = "^0.10"
|
||||
|
@ -1,6 +1,12 @@
|
||||
import pytest
|
||||
|
||||
from falyx.action import Action, ChainedAction, FallbackAction, LiteralInputAction
|
||||
from falyx.action import (
|
||||
Action,
|
||||
ActionGroup,
|
||||
ChainedAction,
|
||||
FallbackAction,
|
||||
LiteralInputAction,
|
||||
)
|
||||
from falyx.context import ExecutionContext
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
|
||||
@ -38,14 +44,13 @@ async def test_action_async_callable():
|
||||
action = Action("test_action", async_callable)
|
||||
result = await action()
|
||||
assert result == "Hello, World!"
|
||||
print(action)
|
||||
assert (
|
||||
str(action)
|
||||
== "Action(name='test_action', action=async_callable, retry=False, rollback=False)"
|
||||
== "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False, rollback=False)"
|
||||
)
|
||||
assert (
|
||||
repr(action)
|
||||
== "Action(name='test_action', action=async_callable, retry=False, rollback=False)"
|
||||
== "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False, rollback=False)"
|
||||
)
|
||||
|
||||
|
||||
@ -60,11 +65,12 @@ async def test_chained_action():
|
||||
return_list=True,
|
||||
)
|
||||
|
||||
print(chain)
|
||||
result = await chain()
|
||||
assert result == [1, 2]
|
||||
assert (
|
||||
str(chain)
|
||||
== "ChainedAction(name='Simple Chain', actions=['one', 'two'], auto_inject=False, return_list=True)"
|
||||
== "ChainedAction(name=Simple Chain, actions=['one', 'two'], args=(), kwargs={}, auto_inject=False, return_list=True)"
|
||||
)
|
||||
|
||||
|
||||
@ -73,17 +79,17 @@ async def test_action_group():
|
||||
"""Test if ActionGroup can be created and used."""
|
||||
action1 = Action("one", lambda: 1)
|
||||
action2 = Action("two", lambda: 2)
|
||||
group = ChainedAction(
|
||||
group = ActionGroup(
|
||||
name="Simple Group",
|
||||
actions=[action1, action2],
|
||||
return_list=True,
|
||||
)
|
||||
|
||||
print(group)
|
||||
result = await group()
|
||||
assert result == [1, 2]
|
||||
assert result == [("one", 1), ("two", 2)]
|
||||
assert (
|
||||
str(group)
|
||||
== "ChainedAction(name='Simple Group', actions=['one', 'two'], auto_inject=False, return_list=True)"
|
||||
== "ActionGroup(name=Simple Group, actions=['one', 'two'], args=(), kwargs={}, inject_last_result=False, inject_into=last_result)"
|
||||
)
|
||||
|
||||
|
||||
|
145
tests/test_actions/test_action_types.py
Normal file
145
tests/test_actions/test_action_types.py
Normal file
@ -0,0 +1,145 @@
|
||||
import pytest
|
||||
|
||||
from falyx.action.action_types import ConfirmType, FileType, SelectionReturnType
|
||||
|
||||
|
||||
def test_file_type_enum():
|
||||
"""Test if the FileType enum has all expected members."""
|
||||
assert FileType.TEXT.value == "text"
|
||||
assert FileType.PATH.value == "path"
|
||||
assert FileType.JSON.value == "json"
|
||||
assert FileType.TOML.value == "toml"
|
||||
assert FileType.YAML.value == "yaml"
|
||||
assert FileType.CSV.value == "csv"
|
||||
assert FileType.TSV.value == "tsv"
|
||||
assert FileType.XML.value == "xml"
|
||||
|
||||
assert str(FileType.TEXT) == "text"
|
||||
|
||||
|
||||
def test_file_type_choices():
|
||||
"""Test if the FileType choices method returns all enum members."""
|
||||
choices = FileType.choices()
|
||||
assert len(choices) == 8
|
||||
assert all(isinstance(choice, FileType) for choice in choices)
|
||||
|
||||
|
||||
def test_file_type_missing():
|
||||
"""Test if the _missing_ method raises ValueError for invalid values."""
|
||||
with pytest.raises(ValueError, match="Invalid FileType: 'invalid'"):
|
||||
FileType._missing_("invalid")
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid FileType: 123"):
|
||||
FileType._missing_(123)
|
||||
|
||||
|
||||
def test_file_type_aliases():
|
||||
"""Test if the _get_alias method returns correct aliases."""
|
||||
assert FileType._get_alias("file") == "path"
|
||||
assert FileType._get_alias("filepath") == "path"
|
||||
assert FileType._get_alias("unknown") == "unknown"
|
||||
|
||||
|
||||
def test_file_type_missing_aliases():
|
||||
"""Test if the _missing_ method handles aliases correctly."""
|
||||
assert FileType._missing_("file") == FileType.PATH
|
||||
assert FileType._missing_("filepath") == FileType.PATH
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid FileType: 'unknown'"):
|
||||
FileType._missing_("unknown")
|
||||
|
||||
|
||||
def test_confirm_type_enum():
|
||||
"""Test if the ConfirmType enum has all expected members."""
|
||||
assert ConfirmType.YES_NO.value == "yes_no"
|
||||
assert ConfirmType.YES_CANCEL.value == "yes_cancel"
|
||||
assert ConfirmType.YES_NO_CANCEL.value == "yes_no_cancel"
|
||||
assert ConfirmType.TYPE_WORD.value == "type_word"
|
||||
assert ConfirmType.TYPE_WORD_CANCEL.value == "type_word_cancel"
|
||||
assert ConfirmType.OK_CANCEL.value == "ok_cancel"
|
||||
assert ConfirmType.ACKNOWLEDGE.value == "acknowledge"
|
||||
|
||||
assert str(ConfirmType.YES_NO) == "yes_no"
|
||||
|
||||
|
||||
def test_confirm_type_choices():
|
||||
"""Test if the ConfirmType choices method returns all enum members."""
|
||||
choices = ConfirmType.choices()
|
||||
assert len(choices) == 7
|
||||
assert all(isinstance(choice, ConfirmType) for choice in choices)
|
||||
|
||||
|
||||
def test_confirm_type_missing():
|
||||
"""Test if the _missing_ method raises ValueError for invalid values."""
|
||||
with pytest.raises(ValueError, match="Invalid ConfirmType: 'invalid'"):
|
||||
ConfirmType._missing_("invalid")
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid ConfirmType: 123"):
|
||||
ConfirmType._missing_(123)
|
||||
|
||||
|
||||
def test_confirm_type_aliases():
|
||||
"""Test if the _get_alias method returns correct aliases."""
|
||||
assert ConfirmType._get_alias("yes") == "yes_no"
|
||||
assert ConfirmType._get_alias("ok") == "ok_cancel"
|
||||
assert ConfirmType._get_alias("type") == "type_word"
|
||||
assert ConfirmType._get_alias("word") == "type_word"
|
||||
assert ConfirmType._get_alias("word_cancel") == "type_word_cancel"
|
||||
assert ConfirmType._get_alias("ack") == "acknowledge"
|
||||
|
||||
|
||||
def test_confirm_type_missing_aliases():
|
||||
"""Test if the _missing_ method handles aliases correctly."""
|
||||
assert ConfirmType("yes") == ConfirmType.YES_NO
|
||||
assert ConfirmType("ok") == ConfirmType.OK_CANCEL
|
||||
assert ConfirmType("word") == ConfirmType.TYPE_WORD
|
||||
assert ConfirmType("ack") == ConfirmType.ACKNOWLEDGE
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid ConfirmType: 'unknown'"):
|
||||
ConfirmType._missing_("unknown")
|
||||
|
||||
|
||||
def test_selection_return_type_enum():
|
||||
"""Test if the SelectionReturnType enum has all expected members."""
|
||||
assert SelectionReturnType.KEY.value == "key"
|
||||
assert SelectionReturnType.VALUE.value == "value"
|
||||
assert SelectionReturnType.DESCRIPTION.value == "description"
|
||||
assert SelectionReturnType.DESCRIPTION_VALUE.value == "description_value"
|
||||
assert SelectionReturnType.ITEMS.value == "items"
|
||||
|
||||
assert str(SelectionReturnType.KEY) == "key"
|
||||
|
||||
|
||||
def test_selection_return_type_choices():
|
||||
"""Test if the SelectionReturnType choices method returns all enum members."""
|
||||
choices = SelectionReturnType.choices()
|
||||
assert len(choices) == 5
|
||||
assert all(isinstance(choice, SelectionReturnType) for choice in choices)
|
||||
|
||||
|
||||
def test_selection_return_type_missing():
|
||||
"""Test if the _missing_ method raises ValueError for invalid values."""
|
||||
with pytest.raises(ValueError, match="Invalid SelectionReturnType: 'invalid'"):
|
||||
SelectionReturnType._missing_("invalid")
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid SelectionReturnType: 123"):
|
||||
SelectionReturnType._missing_(123)
|
||||
|
||||
|
||||
def test_selection_return_type_aliases():
|
||||
"""Test if the _get_alias method returns correct aliases."""
|
||||
assert SelectionReturnType._get_alias("desc") == "description"
|
||||
assert SelectionReturnType._get_alias("desc_value") == "description_value"
|
||||
assert SelectionReturnType._get_alias("unknown") == "unknown"
|
||||
|
||||
|
||||
def test_selection_return_type_missing_aliases():
|
||||
"""Test if the _missing_ method handles aliases correctly."""
|
||||
assert SelectionReturnType._missing_("desc") == SelectionReturnType.DESCRIPTION
|
||||
assert (
|
||||
SelectionReturnType._missing_("desc_value")
|
||||
== SelectionReturnType.DESCRIPTION_VALUE
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid SelectionReturnType: 'unknown'"):
|
||||
SelectionReturnType._missing_("unknown")
|
94
tests/test_actions/test_confirm_action.py
Normal file
94
tests/test_actions/test_confirm_action.py
Normal file
@ -0,0 +1,94 @@
|
||||
import pytest
|
||||
|
||||
from falyx.action import ConfirmAction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_action_yes_no():
|
||||
action = ConfirmAction(
|
||||
name="test",
|
||||
prompt_message="Are you sure?",
|
||||
never_prompt=True,
|
||||
confirm_type="yes_no",
|
||||
)
|
||||
|
||||
result = await action()
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_action_yes_cancel():
|
||||
action = ConfirmAction(
|
||||
name="test",
|
||||
prompt_message="Are you sure?",
|
||||
never_prompt=True,
|
||||
confirm_type="yes_cancel",
|
||||
)
|
||||
|
||||
result = await action()
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_action_yes_no_cancel():
|
||||
action = ConfirmAction(
|
||||
name="test",
|
||||
prompt_message="Are you sure?",
|
||||
never_prompt=True,
|
||||
confirm_type="yes_no_cancel",
|
||||
)
|
||||
|
||||
result = await action()
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_action_type_word():
|
||||
action = ConfirmAction(
|
||||
name="test",
|
||||
prompt_message="Are you sure?",
|
||||
never_prompt=True,
|
||||
confirm_type="type_word",
|
||||
)
|
||||
|
||||
result = await action()
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_action_type_word_cancel():
|
||||
action = ConfirmAction(
|
||||
name="test",
|
||||
prompt_message="Are you sure?",
|
||||
never_prompt=True,
|
||||
confirm_type="type_word_cancel",
|
||||
)
|
||||
|
||||
result = await action()
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_action_ok_cancel():
|
||||
action = ConfirmAction(
|
||||
name="test",
|
||||
prompt_message="Are you sure?",
|
||||
never_prompt=True,
|
||||
confirm_type="ok_cancel",
|
||||
)
|
||||
|
||||
result = await action()
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_action_acknowledge():
|
||||
action = ConfirmAction(
|
||||
name="test",
|
||||
prompt_message="Are you sure?",
|
||||
never_prompt=True,
|
||||
confirm_type="acknowledge",
|
||||
)
|
||||
|
||||
result = await action()
|
||||
assert result is True
|
287
tests/test_actions/test_selection_action.py
Normal file
287
tests/test_actions/test_selection_action.py
Normal file
@ -0,0 +1,287 @@
|
||||
import pytest
|
||||
|
||||
from falyx.action import SelectionAction
|
||||
from falyx.selection import SelectionOption
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selection_list_never_prompt_by_value():
|
||||
action = SelectionAction(
|
||||
name="test",
|
||||
selections=["a", "b", "c"],
|
||||
default_selection="b",
|
||||
never_prompt=True,
|
||||
)
|
||||
assert action.never_prompt is True
|
||||
assert action.default_selection == "b"
|
||||
|
||||
result = await action()
|
||||
assert result == "b"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selection_list_never_prompt_by_index():
|
||||
action = SelectionAction(
|
||||
name="test",
|
||||
selections=["a", "b", "c"],
|
||||
default_selection="2",
|
||||
never_prompt=True,
|
||||
)
|
||||
assert action.never_prompt is True
|
||||
assert action.default_selection == "2"
|
||||
|
||||
result = await action()
|
||||
assert result == "c"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selection_list_never_prompt_by_value_multi_select():
|
||||
action = SelectionAction(
|
||||
name="test",
|
||||
selections=["a", "b", "c"],
|
||||
default_selection=["b", "c"],
|
||||
never_prompt=True,
|
||||
number_selections=2,
|
||||
)
|
||||
assert action.never_prompt is True
|
||||
assert action.default_selection == ["b", "c"]
|
||||
|
||||
result = await action()
|
||||
assert result == ["b", "c"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selection_list_never_prompt_by_index_multi_select():
|
||||
action = SelectionAction(
|
||||
name="test",
|
||||
selections=["a", "b", "c"],
|
||||
default_selection=["1", "2"],
|
||||
never_prompt=True,
|
||||
number_selections=2,
|
||||
)
|
||||
assert action.never_prompt is True
|
||||
assert action.default_selection == ["1", "2"]
|
||||
|
||||
result = await action()
|
||||
assert result == ["b", "c"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selection_prompt_dict_never_prompt():
|
||||
action = SelectionAction(
|
||||
name="test",
|
||||
selections={"a": "Alpha", "b": "Beta", "c": "Gamma"},
|
||||
default_selection="b",
|
||||
never_prompt=True,
|
||||
)
|
||||
assert action.never_prompt is True
|
||||
assert action.default_selection == "b"
|
||||
|
||||
result = await action()
|
||||
assert result == "Beta"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selection_prompt_dict_never_prompt_by_value():
|
||||
action = SelectionAction(
|
||||
name="test",
|
||||
selections={"a": "Alpha", "b": "Beta", "c": "Gamma"},
|
||||
default_selection="Beta",
|
||||
never_prompt=True,
|
||||
)
|
||||
assert action.never_prompt is True
|
||||
assert action.default_selection == "Beta"
|
||||
|
||||
result = await action()
|
||||
assert result == "Beta"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selection_prompt_dict_never_prompt_by_key():
|
||||
action = SelectionAction(
|
||||
name="test",
|
||||
selections={"a": "Alpha", "b": "Beta", "c": "Gamma"},
|
||||
default_selection="b",
|
||||
never_prompt=True,
|
||||
)
|
||||
assert action.never_prompt is True
|
||||
assert action.default_selection == "b"
|
||||
|
||||
result = await action()
|
||||
assert result == "Beta"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selection_prompt_map_never_prompt_by_key():
|
||||
prompt_map = {
|
||||
"a": SelectionOption(description="Alpha", value="Alpha Service"),
|
||||
"b": SelectionOption(description="Beta", value="Beta Service"),
|
||||
"c": SelectionOption(description="Gamma", value="Gamma Service"),
|
||||
}
|
||||
action = SelectionAction(
|
||||
name="test",
|
||||
selections=prompt_map,
|
||||
default_selection="c",
|
||||
never_prompt=True,
|
||||
)
|
||||
assert action.never_prompt is True
|
||||
assert action.default_selection == "c"
|
||||
|
||||
result = await action()
|
||||
assert result == "Gamma Service"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selection_prompt_map_never_prompt_by_description():
|
||||
prompt_map = {
|
||||
"a": SelectionOption(description="Alpha", value="Alpha Service"),
|
||||
"b": SelectionOption(description="Beta", value="Beta Service"),
|
||||
"c": SelectionOption(description="Gamma", value="Gamma Service"),
|
||||
}
|
||||
action = SelectionAction(
|
||||
name="test",
|
||||
selections=prompt_map,
|
||||
default_selection="Alpha",
|
||||
never_prompt=True,
|
||||
)
|
||||
assert action.never_prompt is True
|
||||
assert action.default_selection == "Alpha"
|
||||
|
||||
result = await action()
|
||||
assert result == "Alpha Service"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selection_prompt_map_never_prompt_by_value():
|
||||
prompt_map = {
|
||||
"a": SelectionOption(description="Alpha", value="Alpha Service"),
|
||||
"b": SelectionOption(description="Beta", value="Beta Service"),
|
||||
"c": SelectionOption(description="Gamma", value="Gamma Service"),
|
||||
}
|
||||
action = SelectionAction(
|
||||
name="test",
|
||||
selections=prompt_map,
|
||||
default_selection="Beta Service",
|
||||
never_prompt=True,
|
||||
)
|
||||
assert action.never_prompt is True
|
||||
assert action.default_selection == "Beta Service"
|
||||
|
||||
result = await action()
|
||||
assert result == "Beta Service"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selection_prompt_dict_never_prompt_by_value_multi_select():
|
||||
action = SelectionAction(
|
||||
name="test",
|
||||
selections={"a": "Alpha", "b": "Beta", "c": "Gamma"},
|
||||
default_selection=["Beta", "Gamma"],
|
||||
number_selections=2,
|
||||
never_prompt=True,
|
||||
)
|
||||
assert action.never_prompt is True
|
||||
assert action.default_selection == ["Beta", "Gamma"]
|
||||
|
||||
result = await action()
|
||||
assert result == ["Beta", "Gamma"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selection_prompt_dict_never_prompt_by_key_multi_select():
|
||||
action = SelectionAction(
|
||||
name="test",
|
||||
selections={"a": "Alpha", "b": "Beta", "c": "Gamma"},
|
||||
default_selection=["a", "b"],
|
||||
number_selections=2,
|
||||
never_prompt=True,
|
||||
)
|
||||
assert action.never_prompt is True
|
||||
assert action.default_selection == ["a", "b"]
|
||||
|
||||
result = await action()
|
||||
assert result == ["Alpha", "Beta"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selection_prompt_map_never_prompt_by_key_multi_select():
|
||||
prompt_map = {
|
||||
"a": SelectionOption(description="Alpha", value="Alpha Service"),
|
||||
"b": SelectionOption(description="Beta", value="Beta Service"),
|
||||
"c": SelectionOption(description="Gamma", value="Gamma Service"),
|
||||
}
|
||||
action = SelectionAction(
|
||||
name="test",
|
||||
selections=prompt_map,
|
||||
default_selection=["b", "c"],
|
||||
number_selections=2,
|
||||
never_prompt=True,
|
||||
)
|
||||
assert action.never_prompt is True
|
||||
assert action.default_selection == ["b", "c"]
|
||||
|
||||
result = await action()
|
||||
assert result == ["Beta Service", "Gamma Service"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selection_prompt_map_never_prompt_by_description_multi_select():
|
||||
prompt_map = {
|
||||
"a": SelectionOption(description="Alpha", value="Alpha Service"),
|
||||
"b": SelectionOption(description="Beta", value="Beta Service"),
|
||||
"c": SelectionOption(description="Gamma", value="Gamma Service"),
|
||||
}
|
||||
action = SelectionAction(
|
||||
name="test",
|
||||
selections=prompt_map,
|
||||
default_selection=["Alpha", "Gamma"],
|
||||
number_selections=2,
|
||||
never_prompt=True,
|
||||
)
|
||||
assert action.never_prompt is True
|
||||
assert action.default_selection == ["Alpha", "Gamma"]
|
||||
|
||||
result = await action()
|
||||
assert result == ["Alpha Service", "Gamma Service"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selection_prompt_map_never_prompt_by_value_multi_select():
|
||||
prompt_map = {
|
||||
"a": SelectionOption(description="Alpha", value="Alpha Service"),
|
||||
"b": SelectionOption(description="Beta", value="Beta Service"),
|
||||
"c": SelectionOption(description="Gamma", value="Gamma Service"),
|
||||
}
|
||||
action = SelectionAction(
|
||||
name="test",
|
||||
selections=prompt_map,
|
||||
default_selection=["Beta Service", "Alpha Service"],
|
||||
number_selections=2,
|
||||
never_prompt=True,
|
||||
)
|
||||
assert action.never_prompt is True
|
||||
assert action.default_selection == ["Beta Service", "Alpha Service"]
|
||||
|
||||
result = await action()
|
||||
assert result == ["Beta Service", "Alpha Service"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selection_prompt_map_never_prompt_by_value_wildcard():
|
||||
prompt_map = {
|
||||
"a": SelectionOption(description="Alpha", value="Alpha Service"),
|
||||
"b": SelectionOption(description="Beta", value="Beta Service"),
|
||||
"c": SelectionOption(description="Gamma", value="Gamma Service"),
|
||||
}
|
||||
action = SelectionAction(
|
||||
name="test",
|
||||
selections=prompt_map,
|
||||
default_selection=["Beta Service", "Alpha Service"],
|
||||
number_selections="*",
|
||||
never_prompt=True,
|
||||
)
|
||||
assert action.never_prompt is True
|
||||
assert action.default_selection == ["Beta Service", "Alpha Service"]
|
||||
|
||||
result = await action()
|
||||
assert result == ["Beta Service", "Alpha Service"]
|
@ -53,7 +53,7 @@ def test_command_str():
|
||||
print(cmd)
|
||||
assert (
|
||||
str(cmd)
|
||||
== "Command(key='TEST', description='Test Command' action='Action(name='test_action', action=dummy_action, retry=False, rollback=False)')"
|
||||
== "Command(key='TEST', description='Test Command' action='Action(name='test_action', action=dummy_action, args=(), kwargs={}, retry=False, rollback=False)')"
|
||||
)
|
||||
|
||||
|
||||
|
@ -1,11 +1,40 @@
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from argparse import ArgumentParser, Namespace, _SubParsersAction
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from falyx.__main__ import bootstrap, find_falyx_config, main
|
||||
from falyx.__main__ import (
|
||||
bootstrap,
|
||||
find_falyx_config,
|
||||
get_parsers,
|
||||
init_callback,
|
||||
init_config,
|
||||
main,
|
||||
)
|
||||
from falyx.parser import CommandArgumentParser
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fake_home(monkeypatch):
|
||||
"""Redirect Path.home() to a temporary directory for all tests."""
|
||||
temp_home = Path(tempfile.mkdtemp())
|
||||
monkeypatch.setattr(Path, "home", lambda: temp_home)
|
||||
yield temp_home
|
||||
shutil.rmtree(temp_home, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_teardown():
|
||||
"""Fixture to set up and tear down the environment for each test."""
|
||||
cwd = Path.cwd()
|
||||
yield
|
||||
for file in cwd.glob("falyx.yaml"):
|
||||
file.unlink(missing_ok=True)
|
||||
for file in cwd.glob("falyx.toml"):
|
||||
file.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_find_falyx_config():
|
||||
@ -50,3 +79,54 @@ def test_bootstrap_with_global_config():
|
||||
assert str(config_file.parent) in sys.path
|
||||
config_file.unlink()
|
||||
sys.path = sys_path_before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_init_config():
|
||||
"""Test if the init_config function adds the correct argument."""
|
||||
parser = CommandArgumentParser()
|
||||
init_config(parser)
|
||||
args = await parser.parse_args(["test_project"])
|
||||
assert args["name"] == "test_project"
|
||||
|
||||
# Test with default value
|
||||
args = await parser.parse_args([])
|
||||
assert args["name"] == "."
|
||||
|
||||
|
||||
def test_init_callback(tmp_path):
|
||||
"""Test if the init_callback function works correctly."""
|
||||
# Test project initialization
|
||||
args = Namespace(command="init", name=str(tmp_path))
|
||||
init_callback(args)
|
||||
assert (tmp_path / "falyx.yaml").exists()
|
||||
|
||||
|
||||
def test_init_global_callback():
|
||||
# Test global initialization
|
||||
args = Namespace(command="init_global")
|
||||
init_callback(args)
|
||||
assert (Path.home() / ".config" / "falyx" / "tasks.py").exists()
|
||||
assert (Path.home() / ".config" / "falyx" / "falyx.yaml").exists()
|
||||
|
||||
|
||||
def test_get_parsers():
|
||||
"""Test if the get_parsers function returns the correct parsers."""
|
||||
root_parser, subparsers = get_parsers()
|
||||
assert isinstance(root_parser, ArgumentParser)
|
||||
assert isinstance(subparsers, _SubParsersAction)
|
||||
|
||||
# Check if the 'init' command is available
|
||||
init_parser = subparsers.choices.get("init")
|
||||
assert init_parser is not None
|
||||
assert "name" == init_parser._get_positional_actions()[0].dest
|
||||
|
||||
|
||||
def test_main():
|
||||
"""Test if the main function runs with the correct arguments."""
|
||||
|
||||
sys.argv = ["falyx", "run", "?"]
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
assert exc_info.value.code == 0
|
||||
|
@ -88,3 +88,11 @@ def test_argument_equality():
|
||||
assert arg != "not an argument"
|
||||
assert arg is not None
|
||||
assert arg != object()
|
||||
|
||||
|
||||
def test_argument_required():
|
||||
arg = Argument("--foo", dest="foo", required=True)
|
||||
assert arg.required is True
|
||||
|
||||
arg2 = Argument("--bar", dest="bar", required=False)
|
||||
assert arg2.required is False
|
||||
|
@ -8,4 +8,4 @@ def test_argument_action():
|
||||
assert action != "invalid_action"
|
||||
assert action.value == "append"
|
||||
assert str(action) == "append"
|
||||
assert len(ArgumentAction.choices()) == 9
|
||||
assert len(ArgumentAction.choices()) == 10
|
||||
|
18
tests/test_parsers/test_completions.py
Normal file
18
tests/test_parsers/test_completions.py
Normal file
@ -0,0 +1,18 @@
|
||||
import pytest
|
||||
|
||||
from falyx.parser.command_argument_parser import CommandArgumentParser
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"input_tokens, expected",
|
||||
[
|
||||
([""], ["--help", "--tag", "-h"]),
|
||||
(["--ta"], ["--tag"]),
|
||||
(["--tag"], ["analytics", "build"]),
|
||||
],
|
||||
)
|
||||
async def test_suggest_next(input_tokens, expected):
|
||||
parser = CommandArgumentParser(...)
|
||||
parser.add_argument("--tag", choices=["analytics", "build"])
|
||||
assert sorted(parser.suggest_next(input_tokens)) == sorted(expected)
|
47
tests/test_parsers/test_tldr.py
Normal file
47
tests/test_parsers/test_tldr.py
Normal file
@ -0,0 +1,47 @@
|
||||
import pytest
|
||||
|
||||
from falyx.exceptions import CommandArgumentError
|
||||
from falyx.parser.command_argument_parser import CommandArgumentParser
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_tldr_examples():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_tldr_examples(
|
||||
[
|
||||
("example1", "This is the first example."),
|
||||
("example2", "This is the second example."),
|
||||
]
|
||||
)
|
||||
assert len(parser._tldr_examples) == 2
|
||||
assert parser._tldr_examples[0].usage == "example1"
|
||||
assert parser._tldr_examples[0].description == "This is the first example."
|
||||
assert parser._tldr_examples[1].usage == "example2"
|
||||
assert parser._tldr_examples[1].description == "This is the second example."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bad_tldr_examples():
|
||||
parser = CommandArgumentParser()
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_tldr_examples(
|
||||
[
|
||||
("example1", "This is the first example.", "extra_arg"),
|
||||
("example2", "This is the second example."),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_tldr_examples_in_init():
|
||||
parser = CommandArgumentParser(
|
||||
tldr_examples=[
|
||||
("example1", "This is the first example."),
|
||||
("example2", "This is the second example."),
|
||||
]
|
||||
)
|
||||
assert len(parser._tldr_examples) == 2
|
||||
assert parser._tldr_examples[0].usage == "example1"
|
||||
assert parser._tldr_examples[0].description == "This is the first example."
|
||||
assert parser._tldr_examples[1].usage == "example2"
|
||||
assert parser._tldr_examples[1].description == "This is the second example."
|
Reference in New Issue
Block a user