61 Commits

Author SHA1 Message Date
8db7a9e6dc feat(core): advance options/state handling and workflow execution integration
- extend OptionsManager to support multi-namespace option resolution and toggling
- integrate OptionsManager more deeply across Action, ChainedAction, and ActionGroup
- propagate shared runtime configuration through execution layers
- refine action composition model (sequential + parallel execution semantics)
- improve lifecycle consistency across BaseAction, Action, ChainedAction, and ActionGroup
- begin aligning execution flow with centralized context and options handling

wip: routing and root option parsing behavior still in progress
2026-05-10 13:48:06 -04:00
cce92cca09 refactor: align routing internals and refresh framework docstrings
- rename several Falyx and Command internal helpers with leading underscores
- rename parallel terminology to concurrent across ActionGroup and SharedContext
- update completer and routing references to match current routed API names
- add and revise module, class, and method docstrings across core modules
- refresh package copyright headers for 2026
2026-04-13 18:46:33 -04:00
dcec792d32 refactor: make completer routing-aware for namespaces
- route completions through resolve_completion_route instead of one-level command lookup
- add CompletionRoute to model partial completion state
- suggest namespace entries and namespace-level help/TLDR flags while routing
- delegate leaf argv completion to CommandArgumentParser after command resolution
- restore LCP completion behavior with deduping and flag-safe handling
- add namespace completion name iteration and TLDR example support to Falyx
- update completer and completion route documentation
2026-04-12 14:04:06 -04:00
8ece2a5de6 feat(help): add invocation-aware path rendering for nested CLI help
- introduce InvocationContext and InvocationSegment for styled invocation paths
- thread invocation_context through command arg resolution and help/tldr rendering
- render CLI and namespace help from routed context instead of static program formatting
- support per-segment styling for nested namespaces and command paths
- rebase help target context for `help -k` so usage matches the target command path
- clean up context module docs and remove old invocation path formatting helper
2026-04-11 20:00:01 -04:00
30cb8b97b5 feat: add recursive namespace routing and standalone runner polish
- introduce namespace-aware routing with RootParseResult, RouteResult, and InvocationContext
- register submenus as FalyxNamespace entries and resolve them through _entry_map
- refactor FalyxParser to parse only root options and leave recursive routing to Falyx
- add prepare_route, resolve_route, and route dispatch flow to Falyx
- update validator and completer to understand namespace entries and route results
- unify help/TLDR rendering APIs and add custom_tldr support on Command
- tighten Command.resolve_args error handling and parser type validation
- improve CommandRunner dependency validation and argv handling
- add BottomBar.has_items and improve wrapped executor error messages
- add tests for execution options, resolve_args, command runner, and route-aware validation
2026-04-11 11:57:03 -04:00
5d8f3aa603 feat(core): centralize command execution and add standalone command runner
- add CommandExecutor to unify shared command execution lifecycle
  across Falyx and standalone command execution
- add CommandRunner for running a single Command directly as a CLI
  or programmatic entrypoint
- add Command.build() factory and rename parse_args() to resolve_args()
  to clarify the parsing-to-execution boundary
- introduce ExecutionOption and wire execution-scoped flags into
  CommandArgumentParser and Command construction
- refactor Falyx to use FalyxParser/ParseResult and CommandExecutor
  instead of the older argparse-based flow and run_key path
- simplify __main__.py bootstrap by building a bootstrap Falyx instance
  directly and running flx.run()
- improve completer support for preview commands and unique-prefix
  command resolution
- default BottomBar toggle namespace to "default"
- expand module/class docstrings to reflect the new execution architecture
2026-04-07 18:58:24 -04:00
8ce0ffa18e fix(parser,selection): correct None handling and Path type checks
- Ensure required argument validation treats only `None` as missing
  instead of falsy values (e.g., 0, False, empty string)
- Guard SelectionAction default resolution against `None` results
- Replace direct `type == Path` checks with `issubclass(..., Path)`
  for proper handling of Path subclasses across suggestions logic

Improves correctness in argument parsing and selection defaults,
aligning with Falyx’s explicit and predictable behavior goals.
2026-03-29 13:21:28 -04:00
79f7bd6a60 feat: refine menu lifecycle handling and make bottom bar optional
- Allow `bottom_bar` to be `None` in `Falyx` initialization and validation logic.
- Updated error messaging to reflect `None` as a valid bottom bar state.
- Set `FalyxMode.MENU` explicitly when entering interactive menu mode.
- Simplified `menu()` loop by removing unnecessary `asyncio.create_task` wrapping.
- Added `always_start_menu` flag to `run()` to allow returning to the menu after CLI execution.
- Prevent forced `sys.exit()` when `always_start_menu=True` is provided.
- Bumped version to 0.1.86
2025-11-23 19:05:03 -05:00
1ce1b2385b feat(parser): add docstrings, centralized suggestion errors, and improved flag handling
-Added descriptive docstrings across `Falyx` and `CommandArgumentParser` internals:
  - `is_cli_mode`, `get_tip`, and `_render_help` in `falyx.py`
  - Validation and parsing helpers in `command_argument_parser.py` (`_validate_nargs`, `_normalize_choices`, `_validate_default_list_type`, `_validate_action`, `_register_store_bool_optional`, `_register_argument`, `_check_if_in_choices`, `_raise_remaining_args_error`, `_consume_nargs`, `_consume_all_positional_args`, `_handle_token`, `_find_last_flag_argument`, `_is_mid_value`, `_is_invalid_choices_state`, `_value_suggestions_for_arg`)
- Introduced `_raise_suggestion_error()` utility to standardize error messages when required values are missing, including defaults and choices.
  - Replaced duplicated inline suggestion/error logic in `APPEND`, `EXTEND`, and generic STORE handlers with this helper.
- Improved error chaining with `from error` for clarity in `_normalize_choices` and `_validate_action`.
- Consolidated `HELP`, `TLDR`, and `COUNT` default-value validation into a single check.
- Enhanced completions:
  - Extended suggestion logic to show remaining flags for `APPEND`, `EXTEND`, and `COUNT` arguments when last tokens are not keywords.
- Added `.config.json` to `.gitignore`.
- Bumped version to 0.1.85.
2025-08-22 05:32:36 -04:00
06bf0e432c feat(help): improve TLDR/help handling for help context commands
- Added `from_help` flag to `get_command()` to allow help rendering without full CLI execution flow.
- Updated `_render_help()` to pass `from_help=True` when fetching commands.
- Enhanced TLDR parsing:
  - Allow TLDR flag to be processed and retained when running inside a help command (`_is_help_command=True`).
  - Skip removing `"tldr"` from results in help context to preserve intended behavior.
  - Ensure TLDR args are still marked consumed to maintain state consistency.
- Simplified required argument validation to skip both `help` and `tldr` without special action checks.
- Adjusted `parse_args_split()` to include `tldr` values in help commands while skipping them for normal commands.
- Expanded `infer_args_from_func()` docstring with supported features and parameter handling details.
- Bumped version to 0.1.84.
2025-08-11 19:51:49 -04:00
169f228c92 feat(parser): POSIX bundling, multi-value/default validation, smarter completions; help UX & examples
- Mark help parser with `_is_help_command=True` so CLI renders as `program help`.
- Add TLDR examples to `Exit` and `History` commands.
- Normalize help TLDR/tag docs to short forms `-T` (tldr) and `-t [TAG]`.
- Also propagate submenu exit help text TLDRs when set.
- Disallow defaults for `HELP`, `TLDR`, `COUNT`, and boolean store actions.
- Enforce list defaults for `APPEND`/`EXTEND` and any `nargs` in `{int, "*", "+"}`; coerce to list when `nargs == 1`.
- Validate default(s) against `choices` (lists must be subset).
- Strengthen `choices` checking at parse-time for both scalars and lists; track invalid-choice state for UX.
- New `_resolve_posix_bundling()` with context:
  - Won’t split negative numbers or dash-prefixed positional/path values.
  - Uses the *last seen flag’s type/action* to decide if a dash token is a value vs. bundle.
- Add `_is_valid_dash_token_positional_value()` and `_find_last_flag_argument()` helpers.
- Completions overhaul
  - Track `consumed_position` and `has_invalid_choice` per-arg (via new `ArgumentState.set_consumed()` / `reset()`).
  - Add `_is_mid_value()` and `_value_suggestions_for_arg()` to produce value suggestions while typing.
  - Persist value context for multi-value args (`nargs="*"`, `"+"`) for each call to parse_args
  - Suppress suggestions when a choice is currently invalid, then recover as the prefix becomes valid.
  - Respect `cursor_at_end_of_token`; do not mutate the user’s prefix; improve path suggestions (`"."` vs prefix).
  - Better behavior after a space: suggest remaining flags when appropriate.
- Consistent `index` naming (vs `i`) and propagate `base_index` into positional consumption to mark positions accurately.
- Return value tweaks for `find_argument_by_dest()` and minor readability changes.
- Replace the minimal completion test with a comprehensive suite covering:
  - Basics (defaults, option parsing, lists, booleans).
  - Validation edges (default/choices, `nargs` list requirements).
  - POSIX bundling (flags only; negative values; dash-prefixed paths).
  - Completions for flags/values/mid-value/path/`nargs="*"` persistence.
  - `store_bool_optional` (feature / no-feature, last one wins).
  - Invalid choice suppression & recovery.
  - Repeated keywords (last one wins) and completion context follows the last.
  - File-system-backed path suggestions.
- Bumped version to 0.1.83.
2025-08-10 15:55:45 -04:00
0417a06ee4 feat: enhance help command UX, completions, and CLI tips
- Expanded help command to accept:
  - `-k/--key` for detailed help on a specific command
  - `-t/--tag` for tag-filtered listings
  - `-T/--tldr` for quick usage examples
- Updated TLDR flag to support `-T` short form and refined help text.
- Improved `_render_help()` to show contextual CLI tips after help or TLDR output.
- Adjusted completer to yield both upper and lower case completions without mutating the prefix.
- Standardized CLI tip strings in root/arg parsers to reference `help` and `preview` subcommands instead of menu `run ?` syntax.
- Passed `options_manager` to history/help/exit commands for consistency.
- Allowed `help_command` to display TLDR examples when invoked without a key.
- Added test assertions for help command key/alias consistency.
- Bumped version to 0.1.82.
2025-08-07 19:27:59 -04:00
55d581b870 feat: redesign help command, improve completer UX, and document Falyx CLI
- Renamed CLI subcommand from `list` to `help` for clarity and discoverability.
- Added `--key` and `--tldr` support to the `help` command for detailed and example-based output.
- Introduced `FalyxMode.HELP` to clearly delineate help-related behavior.
- Enhanced `_render_help()` to support:
  - Tag filtering (`--tag`)
  - Per-command help (`--key`)
  - TLDR example rendering (`--tldr`)
- Updated built-in Help command to:
  - Use `FalyxMode.HELP` internally
  - Provide fallback messages for missing help or TLDR data
  - Remove `LIST` alias (replaced by `help`)
- Documented `FalyxCompleter`:
  - Improved docstrings for public methods and completions
  - Updated internal documentation to reflect all supported completion cases
- Updated `CommandArgumentParser.render_tldr()` with fallback message for missing TLDR entries.
- Updated all parser docstrings and variable names to reference `help` (not `list`) as the proper CLI entrypoint.
- Added test coverage:
  - `tests/test_falyx/test_help.py` for CLI `help` command with `tag`, `key`, `tldr`, and fallback scenarios
  - `tests/test_falyx/test_run.py` for basic CLI parser integration
- Bumped version to 0.1.81
2025-08-06 20:33:51 -04:00
a25888f316 feat: add path completion, LCP-based suggestions, and validator tests
- Refactored `FalyxCompleter` to support longest common prefix (LCP) completions by default.
- Added `_ensure_quote` helper to auto-quote completions containing spaces/tabs.
- Integrated `_yield_lcp_completions` for consistent completion insertion logic.
- Added `_suggest_paths()` helper to dynamically suggest filesystem paths for arguments of type `Path`.
- Integrated path completion into `suggest_next()` for both positional and flagged arguments.
- Updated `argument_examples.py` to include a `--path` argument (`Path | None`), demonstrating file path completion.
- Enabled `CompleteStyle.COLUMN` for tab-completion menu formatting in interactive sessions.
- Improved bottom bar docstring formatting with fenced code block examples.
- Added safeguard to `word_validator` to reject `"N"` since it’s reserved for `yes_no_validator`.
- Improved help panel rendering for commands (using `Padding` + `Panel`).
- Added full test coverage for:
  - `FalyxCompleter` and LCP behavior (`tests/test_completer/`)
  - All validators (`tests/test_validators/`)
- Bumped version to 0.1.80.
2025-08-03 18:10:32 -04:00
8e306b9eaf feat(run): improve run-all handling, clarify exit codes, and enhance documentation
- Expanded `Falyx.run()` docstring into a detailed Google‑style docstring:
- Refined exit code semantics:
- `QuitSignal` now exits with code 130 (Ctrl+C style)
- `BackSignal` and `CancelSignal` exit with code 1 instead of 0 for script correctness
- Reworked `run-all` execution flow:
- Uses `asyncio.gather()` to run tagged commands concurrently
- Aggregates exceptions and signals for clearer reporting
- Tracks `had_errors` flag and exits with code 1 if any commands fail
- Bumped version to **0.1.79**

These changes make `run-all` safer for automation, standardize exit codes, and provide richer documentation for developers using the CLI.
2025-07-30 23:41:25 -04:00
3b2c33d28f feat(help & spinners): improve help rendering, async spinner handling, and pipeline demo
- Refactored `Command.help_signature` to return `(usage, description, tags)` instead of a Rich `Padding`/`Panel`.
- Replaced `show_help()` with `render_help()` in `Command` and `Falyx`.
- Updated Falyx help rendering to use Rich `Panel`/`Padding` consistently for cleaner UI.
- Swapped `print()` calls for `console.print()` for styled output.
- Added hooks to `ProcessAction` to announce analysis start/finish.
- Added spinners to test and deploy steps; simplified retry setup.
- Converted `remove()` to `async def remove()` for consistency.
- Added async lock to prevent concurrent Live loop start/stop races.
- Added debug logging when starting/stopping the Live loop.
- Updated `spinner_teardown_hook` to `await sm.remove(...)` to align with async `remove()`.
- Removed `rich.panel`/`rich.padding` from `Command` since panels are now built in `Falyx` help rendering.
- Bumped `rich` dependency to `^14.0`.
- Bumped version to 0.1.78.

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

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

1
.gitignore vendored
View File

@@ -15,3 +15,4 @@ build/
.vscode/ .vscode/
coverage.xml coverage.xml
.coverage .coverage
.config.json

154
README.md
View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import asyncio import asyncio
from falyx import Falyx from falyx import Falyx
from falyx.action import ActionFactoryAction, ChainedAction, HTTPAction, SelectionAction from falyx.action import ActionFactory, ChainedAction, HTTPAction, SelectionAction
# Selection of a post ID to fetch (just an example set) # Selection of a post ID to fetch (just an example set)
post_selector = SelectionAction( post_selector = SelectionAction(
@@ -24,7 +24,7 @@ async def build_post_action(post_id) -> HTTPAction:
) )
post_factory = ActionFactoryAction( post_factory = ActionFactory(
name="Build HTTPAction from Post ID", name="Build HTTPAction from Post ID",
factory=build_post_action, factory=build_post_action,
inject_last_result=True, inject_last_result=True,

View File

@@ -0,0 +1,191 @@
import asyncio
from enum import Enum
from pathlib import Path
from falyx import Falyx
from falyx.action import Action
from falyx.parser.command_argument_parser import CommandArgumentParser
class Place(Enum):
"""Enum for different places."""
NEW_YORK = "New York"
SAN_FRANCISCO = "San Francisco"
LONDON = "London"
def __str__(self):
return self.value
async def test_args(
service: str,
place: Place = Place.NEW_YORK,
region: str = "us-east-1",
path: Path | None = None,
tag: str | None = None,
verbose: bool | None = None,
numbers: list[int] | None = None,
just_a_bool: bool = False,
) -> str:
if numbers is None:
numbers = []
if verbose:
print(
f"Deploying {service}:{tag}:{"|".join(str(number) for number in numbers)} to {region} at {place} from {path}..."
)
return f"{service}:{tag}:{"|".join(str(number) for number in numbers)} deployed to {region} at {place} from {path}."
async def test_path_arg(*paths: Path) -> str:
return f"Path argument received: {'|'.join(str(path) for path in paths)}"
async def test_positional_numbers(*numbers: int) -> str:
return f"Positional numbers received: {', '.join(str(num) for num in numbers)}"
def default_config(parser: CommandArgumentParser) -> None:
"""Default argument configuration for the command."""
parser.add_argument(
"service",
type=str,
choices=["web", "database", "cache"],
help="Service name to deploy.",
)
parser.add_argument(
"place",
type=Place,
choices=list(Place),
default=Place.NEW_YORK,
help="Place where the service will be deployed.",
)
parser.add_argument(
"--region",
type=str,
default="us-east-1",
help="Deployment region.",
choices=["us-east-1", "us-west-2", "eu-west-1"],
)
parser.add_argument(
"-p",
"--path",
type=Path,
help="Path to the configuration file.",
)
parser.add_argument(
"--verbose",
action="store_bool_optional",
help="Enable verbose output.",
)
parser.add_argument(
"-t",
"--tag",
type=str,
help="Optional tag for the deployment.",
suggestions=["latest", "stable", "beta"],
)
parser.add_argument(
"--numbers",
type=int,
nargs="*",
default=[1, 2, 3],
help="Optional number argument.",
)
parser.add_argument(
"-j",
"--just-a-bool",
action="store_true",
help="Just a boolean flag.",
)
parser.add_tldr_examples(
[
("web", "Deploy 'web' to the default location (New York)"),
("cache London --tag beta", "Deploy 'cache' to London with tag"),
("database --region us-west-2 --verbose", "Verbose deploy to west region"),
]
)
def path_config(parser: CommandArgumentParser) -> None:
"""Argument configuration for path testing command."""
parser.add_argument(
"paths",
type=Path,
nargs="*",
help="One or more file or directory paths.",
)
parser.add_tldr_examples(
[
("/path/to/file.txt", "Single file path"),
("/path/to/dir1 /path/to/dir2", "Multiple directory paths"),
("/path/with spaces/file.txt", "Path with spaces"),
]
)
def numbers_config(parser: CommandArgumentParser) -> None:
"""Argument configuration for positional numbers testing command."""
parser.add_argument(
"numbers",
type=int,
nargs="*",
help="One or more integers.",
)
parser.add_tldr_examples(
[
("1 2 3", "Three numbers"),
("42", "Single number"),
("", "No numbers"),
]
)
flx = Falyx(
"Argument Examples",
program="argument_examples.py",
hide_menu_table=True,
show_placeholder_menu=True,
enable_prompt_history=True,
)
flx.add_command(
key="T",
aliases=["test"],
description="Test Command",
help_text="A command to test argument parsing.",
action=Action(
name="test_args",
action=test_args,
),
style="bold #B3EBF2",
argument_config=default_config,
)
flx.add_command(
key="P",
aliases=["path"],
description="Path Command",
help_text="A command to test path argument parsing.",
action=Action(
name="test_path_arg",
action=test_path_arg,
),
style="bold #F2B3EB",
argument_config=path_config,
)
flx.add_command(
key="N",
aliases=["numbers"],
description="Numbers Command",
help_text="A command to test positional numbers argument parsing.",
action=Action(
name="test_positional_numbers",
action=test_positional_numbers,
),
style="bold #F2F2B3",
argument_config=numbers_config,
)
asyncio.run(flx.run())

View File

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

View File

@@ -1,6 +1,7 @@
import asyncio import asyncio
from falyx import Action, ChainedAction, Falyx from falyx import Falyx
from falyx.action import Action, ChainedAction
from falyx.utils import setup_logging from falyx.utils import setup_logging
setup_logging() setup_logging()
@@ -20,16 +21,21 @@ flx = Falyx("Deployment CLI")
flx.add_command( flx.add_command(
key="D", key="D",
aliases=["deploy"], aliases=["deploy"],
description="Deploy a service to a specified region.", description="Deploy",
help_text="Deploy a service to a specified region.",
action=Action( action=Action(
name="deploy_service", name="deploy_service",
action=deploy, action=deploy,
), ),
arg_metadata={ arg_metadata={
"service": "Service name", "service": "Service name",
"region": {"help": "Deployment region", "choices": ["us-east-1", "us-west-2"]}, "region": {
"help": "Deployment region",
"choices": ["us-east-1", "us-west-2", "eu-west-1"],
},
"verbose": {"help": "Enable verbose mode"}, "verbose": {"help": "Enable verbose mode"},
}, },
tags=["deployment", "service"],
) )
deploy_chain = ChainedAction( deploy_chain = ChainedAction(
@@ -47,8 +53,10 @@ deploy_chain = ChainedAction(
flx.add_command( flx.add_command(
key="N", key="N",
aliases=["notify"], aliases=["notify"],
description="Deploy a service and notify.", description="Deploy and Notify",
help_text="Deploy a service and notify.",
action=deploy_chain, action=deploy_chain,
tags=["deployment", "service", "notification"],
) )
asyncio.run(flx.run()) asyncio.run(flx.run())

127
examples/confirm_example.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -2,8 +2,8 @@ import asyncio
from rich.console import Console from rich.console import Console
from falyx import ActionGroup, Falyx from falyx import Falyx
from falyx.action import HTTPAction from falyx.action import ActionGroup, HTTPAction
from falyx.hooks import ResultReporter from falyx.hooks import ResultReporter
console = Console() console = Console()

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
import asyncio import asyncio
from uuid import uuid4
from falyx import Falyx
from falyx.action import SelectionAction from falyx.action import SelectionAction
from falyx.selection import SelectionOption from falyx.selection import SelectionOption
from falyx.signals import CancelSignal from falyx.signals import CancelSignal
@@ -24,7 +26,45 @@ select = SelectionAction(
show_table=True, show_table=True,
) )
try: list_selections = [uuid4() for _ in range(10)]
list_select = SelectionAction(
name="Select Deployments",
selections=list_selections,
title="Select Deployments",
columns=3,
prompt_message="Select 3 Deployments > ",
return_type="value",
show_table=True,
number_selections=3,
)
flx = Falyx()
flx.add_command(
key="S",
description="Select a deployment",
action=select,
help_text="Select a deployment from the list",
)
flx.add_command(
key="L",
description="Select deployments",
action=list_select,
help_text="Select multiple deployments from the list",
)
if __name__ == "__main__":
try:
print(asyncio.run(select())) print(asyncio.run(select()))
except CancelSignal: except CancelSignal:
print("Selection was cancelled.") print("Selection was cancelled.")
try:
print(asyncio.run(list_select()))
except CancelSignal:
print("Selection was cancelled.")
asyncio.run(flx.run())

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env python #!/usr/bin/env python
import asyncio import asyncio
from falyx import Action, ChainedAction, Falyx from falyx import Falyx
from falyx.action import ShellAction from falyx.action import Action, ChainedAction, ShellAction
from falyx.hooks import ResultReporter from falyx.hooks import ResultReporter
from falyx.utils import setup_logging from falyx.utils import setup_logging

View File

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

View File

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

100
examples/type_validation.py Normal file
View File

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

View File

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

View File

@@ -1,29 +1,18 @@
""" """Falyx CLI Framework
Falyx CLI Framework
Copyright (c) 2025 rtj.dev LLC. Copyright (c) 2026 rtj.dev LLC.
Licensed under the MIT License. See LICENSE file for details. Licensed under the MIT License. See LICENSE file for details.
""" """
import logging import logging
from .action.action import Action, ActionGroup, ChainedAction, ProcessAction
from .command import Command
from .context import ExecutionContext, SharedContext
from .execution_registry import ExecutionRegistry from .execution_registry import ExecutionRegistry
from .falyx import Falyx from .falyx import Falyx
logger = logging.getLogger("falyx") logger = logging.getLogger("falyx")
__all__ = [ __all__ = [
"Action",
"ChainedAction",
"ActionGroup",
"ProcessAction",
"Falyx", "Falyx",
"Command",
"ExecutionContext",
"SharedContext",
"ExecutionRegistry", "ExecutionRegistry",
"HookType",
] ]

View File

@@ -1,20 +1,18 @@
""" """Falyx CLI Framework
Falyx CLI Framework
Copyright (c) 2025 rtj.dev LLC. Copyright (c) 2026 rtj.dev LLC.
Licensed under the MIT License. See LICENSE file for details. Licensed under the MIT License. See LICENSE file for details.
""" """
import asyncio import asyncio
import os import os
import sys import sys
from argparse import Namespace
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from falyx.config import loader from falyx.config import loader
from falyx.falyx import Falyx from falyx.falyx import Falyx
from falyx.parsers import FalyxParsers, get_arg_parsers from falyx.parser import CommandArgumentParser
def find_falyx_config() -> Path | None: def find_falyx_config() -> Path | None:
@@ -39,45 +37,50 @@ def bootstrap() -> Path | None:
return config_path return config_path
def get_falyx_parsers() -> FalyxParsers: def init_config(parser: CommandArgumentParser) -> None:
falyx_parsers: FalyxParsers = get_arg_parsers() parser.add_argument(
init_parser = falyx_parsers.subparsers.add_parser( "name",
"init", help="Create a new Falyx CLI project" type=str,
help="Name of the new Falyx project",
default=".",
nargs="?",
) )
init_parser.add_argument("name", nargs="?", default=".", help="Project directory")
falyx_parsers.subparsers.add_parser(
"init-global", help="Set up ~/.config/falyx with example tasks" def build_bootstrap_falyx() -> Falyx:
from falyx.init import init_global, init_project
flx = Falyx()
flx.add_command(
"I",
"Initialize a new Falyx project",
init_project,
aliases=["init"],
argument_config=init_config,
help_epilog="If no name is provided, the current directory will be used.",
) )
return falyx_parsers flx.add_command(
"G",
"Initialize Falyx global configuration",
init_global,
aliases=["init-global"],
help_text="Create a global Falyx configuration at ~/.config/falyx/.",
)
return flx
def run(args: Namespace) -> Any: def build_falyx() -> Falyx:
if args.command == "init":
from falyx.init import init_project
init_project(args.name)
return
if args.command == "init-global":
from falyx.init import init_global
init_global()
return
bootstrap_path = bootstrap() bootstrap_path = bootstrap()
if not bootstrap_path: if bootstrap_path:
print("No Falyx config file found. Exiting.") return loader(bootstrap_path)
return None return build_bootstrap_falyx()
flx: Falyx = loader(bootstrap_path)
def main() -> Any:
flx = build_falyx()
return asyncio.run(flx.run()) return asyncio.run(flx.run())
def main():
parsers = get_falyx_parsers()
args = parsers.parse_args()
run(args)
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,183 @@
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Core action system for Falyx.
This module defines the building blocks for executable actions and workflows,
providing a structured way to compose, execute, recover, and manage sequences of
operations.
All actions are callable and follow a unified signature:
result = action(*args, **kwargs)
Core guarantees:
- Full hook lifecycle support (before, on_success, on_error, after, on_teardown).
- Consistent timing and execution context tracking for each run.
- Unified, predictable result handling and error propagation.
- Optional last_result injection to enable flexible, data-driven workflows.
- Built-in support for retries, rollbacks, concurrent groups, chaining, and fallback
recovery.
Key components:
- Action: wraps a function or coroutine into a standard executable unit.
- ChainedAction: runs actions sequentially, optionally injecting last results.
- ActionGroup: runs actions concurrently and gathers results.
- ProcessAction: executes CPU-bound functions in a separate process.
- LiteralInputAction: injects static values into workflows.
- FallbackAction: gracefully recovers from failures or missing data.
This design promotes clean, fault-tolerant, modular CLI and automation systems.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, Callable
from rich.console import Console
from rich.tree import Tree
from falyx.console import console
from falyx.context import SharedContext
from falyx.debug import register_debug_hooks
from falyx.hook_manager import Hook, HookManager, HookType
from falyx.hooks import spinner_before_hook, spinner_teardown_hook
from falyx.logger import logger
from falyx.options_manager import OptionsManager
from falyx.themes import OneColors
class BaseAction(ABC):
"""Base class for actions. Actions can be simple functions or more
complex actions like `ChainedAction` or `ActionGroup`. They can also
be run independently or as part of Falyx.
Args:
name (str): Name of the action. Used for logging and debugging.
hooks (HookManager | None): Hook manager for lifecycle events.
inject_last_result (bool): Whether to inject the previous action's result
into kwargs.
inject_into (str): The name of the kwarg key to inject the result as
(default: 'last_result').
never_prompt (bool | None): Whether to never prompt for input.
logging_hooks (bool): Whether to register debug hooks for logging.
ignore_in_history (bool): Whether to ignore this action in execution history last result.
"""
def __init__(
self,
name: str,
*,
hooks: HookManager | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
never_prompt: bool | None = None,
logging_hooks: bool = False,
ignore_in_history: bool = False,
spinner: bool = False,
spinner_message: str = "Processing...",
spinner_type: str = "dots",
spinner_style: str = OneColors.CYAN,
spinner_speed: float = 1.0,
) -> None:
self.name = name
self.hooks = hooks or HookManager()
self.is_retryable: bool = False
self.shared_context: SharedContext | None = None
self.inject_last_result: bool = inject_last_result
self.inject_into: str = inject_into
self._never_prompt: bool | None = never_prompt
self._skip_in_chain: bool = False
self.console: Console = console
self.options_manager: OptionsManager | None = None
self.ignore_in_history: bool = ignore_in_history
self.spinner_message = spinner_message
self.spinner_type = spinner_type
self.spinner_style = spinner_style
self.spinner_speed = spinner_speed
if spinner:
self.hooks.register(HookType.BEFORE, spinner_before_hook)
self.hooks.register(HookType.ON_TEARDOWN, spinner_teardown_hook)
if logging_hooks:
register_debug_hooks(self.hooks)
async def __call__(self, *args, **kwargs) -> Any:
return await self._run(*args, **kwargs)
@abstractmethod
async def _run(self, *args, **kwargs) -> Any:
raise NotImplementedError("_run must be implemented by subclasses")
@abstractmethod
async def preview(self, parent: Tree | None = None):
raise NotImplementedError("preview must be implemented by subclasses")
@abstractmethod
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
"""Returns the callable to be used for argument inference.
By default, it returns None.
"""
raise NotImplementedError("get_infer_target must be implemented by subclasses")
def set_options_manager(self, options_manager: OptionsManager) -> None:
self.options_manager = options_manager
def set_shared_context(self, shared_context: SharedContext) -> None:
self.shared_context = shared_context
def get_option(self, option_name: str, default: Any = None) -> Any:
"""Resolve an option from the OptionsManager if present, else default."""
if self.options_manager:
return self.options_manager.get(option_name, default)
return default
@property
def last_result(self) -> Any:
"""Return the last result from the shared context."""
if self.shared_context:
return self.shared_context.last_result()
return None
@property
def never_prompt(self) -> bool:
if self._never_prompt is not None:
return self._never_prompt
return self.get_option("never_prompt", False)
@property
def spinner_manager(self):
"""Shortcut to access SpinnerManager via the OptionsManager."""
if not self.options_manager:
raise RuntimeError("SpinnerManager is not available (no OptionsManager set).")
return self.options_manager.spinners
def prepare(
self, shared_context: SharedContext, options_manager: OptionsManager | None = None
) -> BaseAction:
"""Prepare the action specifically for sequential (ChainedAction) execution.
Can be overridden for chain-specific logic.
"""
self.set_shared_context(shared_context)
if options_manager:
self.set_options_manager(options_manager)
return self
def _maybe_inject_last_result(self, kwargs: dict[str, Any]) -> dict[str, Any]:
if self.inject_last_result and self.shared_context:
key = self.inject_into
if key in kwargs:
logger.warning("[%s] Overriding '%s' with last_result", self.name, key)
kwargs = dict(kwargs)
kwargs[key] = self.shared_context.last_result()
return kwargs
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
"""Register a hook for all actions and sub-actions."""
self.hooks.register(hook_type, hook)
async def _write_stdout(self, data: str) -> None:
"""Override in subclasses that produce terminal output."""
def __repr__(self) -> str:
return str(self)

View File

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

View File

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

View File

@@ -0,0 +1,85 @@
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Defines `FallbackAction`, a lightweight recovery Action used within `ChainedAction`
pipelines to gracefully handle errors or missing results from a preceding step.
When placed immediately after a failing or null-returning Action, `FallbackAction`
injects the `last_result` and checks whether it is `None`. If so, it substitutes a
predefined fallback value and allows the chain to continue. If `last_result` is valid,
it is passed through unchanged.
This mechanism allows workflows to recover from failure or gaps in data
without prematurely terminating the entire chain.
Key Features:
- Injects and inspects `last_result`
- Replaces `None` with a fallback value
- Consumes upstream errors when used with `ChainedAction`
- Fully compatible with Falyx's preview and hook systems
Typical Use Cases:
- Graceful degradation in chained workflows
- Providing default values when earlier steps are optional
- Replacing missing data with static or precomputed values
Example:
ChainedAction(
name="FetchWithFallback",
actions=[
Action("MaybeFetchRemoteAction", action=fetch_data),
FallbackAction(fallback={"data": "default"}),
Action("ProcessDataAction", action=process_data),
],
auto_inject=True,
)
The `FallbackAction` ensures that even if `MaybeFetchRemoteAction` fails or returns
None, `ProcessDataAction` still receives a usable input.
"""
from functools import cached_property
from typing import Any
from rich.tree import Tree
from falyx.action.action import Action
from falyx.themes import OneColors
class FallbackAction(Action):
"""FallbackAction provides a default value if the previous action failed or
returned None.
It injects the last result and checks:
- If last_result is not None, it passes it through unchanged.
- If last_result is None (e.g., due to failure), it replaces it with a fallback value.
Used in ChainedAction pipelines to gracefully recover from errors or missing data.
When activated, it consumes the preceding error and allows the chain to continue
normally.
Args:
fallback (Any): The fallback value to use if last_result is None.
"""
def __init__(self, fallback: Any):
self._fallback = fallback
async def _fallback_logic(last_result):
return last_result if last_result is not None else fallback
super().__init__(name="Fallback", action=_fallback_logic, inject_last_result=True)
@cached_property
def fallback(self) -> Any:
"""Return the fallback value."""
return self._fallback
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.LIGHT_RED}]🛟 Fallback[/] '{self.name}'"]
label.append(f" [dim](uses fallback = {repr(self.fallback)})[/dim]")
if parent:
parent.add("".join(label))
else:
self.console.print(Tree("".join(label)))
def __str__(self) -> str:
return f"FallbackAction(fallback={self.fallback!r})"

View File

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

View File

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

View File

@@ -0,0 +1,78 @@
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Defines `LiteralInputAction`, a lightweight Falyx Action that injects a static,
predefined value into a `ChainedAction` workflow.
This Action is useful for embedding literal values (e.g., strings, numbers,
dicts) as part of a CLI pipeline without writing custom callables. It behaves
like a constant-returning function that can serve as the starting point,
fallback, or manual override within a sequence of actions.
Key Features:
- Wraps any static value as a Falyx-compatible Action
- Fully hookable and previewable like any other Action
- Enables declarative workflows with no required user input
- Compatible with auto-injection and shared context in `ChainedAction`
Common Use Cases:
- Supplying default parameters or configuration values mid-pipeline
- Starting a chain with a fixed value (e.g., base URL, credentials)
- Bridging gaps between conditional or dynamically generated Actions
Example:
ChainedAction(
name="SendStaticMessage",
actions=[
LiteralInputAction("hello world"),
SendMessageAction(),
]
)
The `LiteralInputAction` is a foundational building block for pipelines that
require predictable, declarative value injection at any stage.
"""
from __future__ import annotations
from functools import cached_property
from typing import Any
from rich.tree import Tree
from falyx.action.action import Action
from falyx.themes import OneColors
class LiteralInputAction(Action):
"""LiteralInputAction injects a static value into a ChainedAction.
This allows embedding hardcoded values mid-pipeline, useful when:
- Providing default or fallback inputs.
- Starting a pipeline with a fixed input.
- Supplying missing context manually.
Args:
value (Any): The static value to inject.
"""
def __init__(self, value: Any):
self._value = value
async def literal(*_, **__):
return value
super().__init__("Input", literal)
@cached_property
def value(self) -> Any:
"""Return the literal value."""
return self._value
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.LIGHT_YELLOW}]📥 LiteralInput[/] '{self.name}'"]
label.append(f" [dim](value = {repr(self.value)})[/dim]")
if parent:
parent.add("".join(label))
else:
self.console.print(Tree("".join(label)))
def __str__(self) -> str:
return f"LiteralInputAction(value={self.value!r})"

View File

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

View File

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

View File

@@ -0,0 +1,179 @@
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Defines `ProcessAction`, a Falyx Action that executes a blocking or CPU-bound function
in a separate process using `concurrent.futures.ProcessPoolExecutor`.
This is useful for offloading expensive computations or subprocess-compatible operations
from the main event loop, while maintaining Falyx's composable, hookable, and injectable
execution model.
`ProcessAction` mirrors the behavior of a normal `Action`, but ensures isolation from
the asyncio event loop and handles serialization (pickling) of arguments and injected
state.
Key Features:
- Runs a callable in a separate Python process
- Compatible with `last_result` injection for chained workflows
- Validates that injected values are pickleable before dispatch
- Supports hook lifecycle (`before`, `on_success`, `on_error`, etc.)
- Custom executor support for reuse or configuration
Use Cases:
- CPU-intensive operations (e.g., image processing, simulations, data transformations)
- Blocking third-party libraries that don't cooperate with asyncio
- CLI workflows that require subprocess-level parallelism or safety
Example:
ProcessAction(
name="ComputeChecksum",
action=calculate_sha256,
args=("large_file.bin",),
)
Raises:
- `ValueError`: If an injected value is not pickleable
- `Exception`: Propagated from the subprocess on failure
This module enables structured offloading of workload in CLI pipelines while maintaining
full introspection and lifecycle management.
"""
from __future__ import annotations
import asyncio
from concurrent.futures import ProcessPoolExecutor
from functools import partial
from typing import Any, Callable
from rich.tree import Tree
from falyx.action.base_action import BaseAction
from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType
from falyx.themes import OneColors
class ProcessAction(BaseAction):
"""ProcessAction runs a function in a separate process using ProcessPoolExecutor.
Features:
- Executes CPU-bound or blocking tasks without blocking the main event loop.
- Supports last_result injection into the subprocess.
- Validates that last_result is pickleable when injection is enabled.
Args:
name (str): Name of the action.
func (Callable): Function to execute in a new process.
args (tuple, optional): Positional arguments.
kwargs (dict, optional): Keyword arguments.
hooks (HookManager, optional): Hook manager for lifecycle events.
executor (ProcessPoolExecutor, optional): Custom executor if desired.
inject_last_result (bool, optional): Inject last result into the function.
inject_into (str, optional): Name of the injected key.
"""
def __init__(
self,
name: str,
action: Callable[..., Any],
*,
args: tuple = (),
kwargs: dict[str, Any] | None = None,
hooks: HookManager | None = None,
executor: ProcessPoolExecutor | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
never_prompt: bool | None = None,
logging_hooks: bool = False,
spinner: bool = False,
spinner_message: str = "Processing...",
spinner_type: str = "dots",
spinner_style: str = OneColors.CYAN,
spinner_speed: float = 1.0,
):
super().__init__(
name,
hooks=hooks,
inject_last_result=inject_last_result,
inject_into=inject_into,
never_prompt=never_prompt,
logging_hooks=logging_hooks,
spinner=spinner,
spinner_message=spinner_message,
spinner_type=spinner_type,
spinner_style=spinner_style,
spinner_speed=spinner_speed,
)
self.action = action
self.args = args
self.kwargs = kwargs or {}
self.executor = executor or ProcessPoolExecutor()
self.is_retryable = True
def get_infer_target(self) -> tuple[Callable[..., Any] | None, None]:
return self.action, None
async def _run(self, *args, **kwargs) -> Any:
if self.inject_last_result and self.shared_context:
last_result = self.shared_context.last_result()
if not self._validate_pickleable(last_result):
raise ValueError(
f"Cannot inject last result into {self.name}: "
f"last result is not pickleable."
)
combined_args = args + self.args
combined_kwargs = self._maybe_inject_last_result({**self.kwargs, **kwargs})
context = ExecutionContext(
name=self.name,
args=combined_args,
kwargs=combined_kwargs,
action=self,
)
loop = asyncio.get_running_loop()
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
result = await loop.run_in_executor(
self.executor, partial(self.action, *combined_args, **combined_kwargs)
)
context.result = result
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return result
except Exception as error:
context.exception = error
await self.hooks.trigger(HookType.ON_ERROR, context)
if context.result is not None:
return context.result
raise
finally:
context.stop_timer()
await self.hooks.trigger(HookType.AFTER, context)
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
er.record(context)
def _validate_pickleable(self, obj: Any) -> bool:
try:
import pickle
pickle.dumps(obj)
return True
except (pickle.PicklingError, TypeError):
return False
async def preview(self, parent: Tree | None = None):
label = [
f"[{OneColors.DARK_YELLOW_b}]🧠 ProcessAction (new process)[/] '{self.name}'"
]
if self.inject_last_result:
label.append(f" [dim](injects '{self.inject_into}')[/dim]")
if parent:
parent.add("".join(label))
else:
self.console.print(Tree("".join(label)))
def __str__(self) -> str:
return (
f"ProcessAction(name={self.name!r}, "
f"action={getattr(self.action, '__name__', repr(self.action))}, "
f"args={self.args!r}, kwargs={self.kwargs!r})"
)

View File

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

View File

@@ -1,24 +1,79 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2026 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 typing import Any
from prompt_toolkit import PromptSession from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import FormattedText, merge_formatted_text from prompt_toolkit.formatted_text import FormattedText, merge_formatted_text
from rich.console import Console
from rich.tree import Tree from rich.tree import Tree
from falyx.action.action import BaseAction from falyx.action.base_action import BaseAction
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType from falyx.hook_manager import HookType
from falyx.logger import logger from falyx.logger import logger
from falyx.menu import MenuOptionMap from falyx.menu import MenuOptionMap
from falyx.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 from falyx.themes import OneColors
class PromptMenuAction(BaseAction): 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__( def __init__(
self, self,
@@ -29,7 +84,6 @@ class PromptMenuAction(BaseAction):
default_selection: str = "", default_selection: str = "",
inject_last_result: bool = False, inject_last_result: bool = False,
inject_into: str = "last_result", inject_into: str = "last_result",
console: Console | None = None,
prompt_session: PromptSession | None = None, prompt_session: PromptSession | None = None,
never_prompt: bool = False, never_prompt: bool = False,
include_reserved: bool = True, include_reserved: bool = True,
@@ -41,10 +95,11 @@ class PromptMenuAction(BaseAction):
never_prompt=never_prompt, never_prompt=never_prompt,
) )
self.menu_options = menu_options 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.default_selection = default_selection
self.console = console or Console(color_system="auto") self.prompt_session = prompt_session or PromptSession(
self.prompt_session = prompt_session or PromptSession() interrupt_exception=CancelSignal
)
self.include_reserved = include_reserved self.include_reserved = include_reserved
def get_infer_target(self) -> tuple[None, None]: def get_infer_target(self) -> tuple[None, None]:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,40 +1,68 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""command.py """Command abstraction for the Falyx CLI framework.
Defines the Command class for Falyx CLI. This module defines the `Command` class, which represents a single executable
unit exposed to users via CLI or interactive menu interfaces.
Commands are callable units representing a menu option or CLI task, A `Command` acts as a bridge between:
wrapping either a BaseAction or a simple function. They provide: - User input (parsed via CommandArgumentParser)
- Execution logic (encapsulated in Action / BaseAction)
- Runtime configuration (OptionsManager)
- Lifecycle hooks (HookManager)
- Hook lifecycle (before, on_success, on_error, after, on_teardown) Core Responsibilities:
- Define command identity (key, aliases, description)
- Bind an executable action or workflow
- Configure argument parsing via CommandArgumentParser
- Separate execution arguments (e.g. retries, confirm) from action arguments
- Manage lifecycle hooks for command-level execution
- Provide help, usage, and preview interfaces
- Execution timing and duration tracking - Execution timing and duration tracking
- Retry logic (single action or recursively through action trees)
- Confirmation prompts and spinner integration - Confirmation prompts and spinner integration
- Result capturing and summary logging
- Rich-based preview for CLI display
Every Command is self-contained, configurable, and plays a critical role Execution Model:
in building robust interactive menus. 1. CLI input is routed via FalyxParser into a resolved Command
2. Arguments are parsed via CommandArgumentParser
3. Parsed values are split into:
- positional args
- keyword args
- execution args (e.g. retries, summary)
4. Execution occurs via the bound Action with lifecycle hooks applied
5. Results and context are tracked via ExecutionContext / ExecutionRegistry
Key Concepts:
- Commands are *user-facing entrypoints*, not execution units themselves
- Execution is always delegated to an underlying Action or callable
- Argument parsing is declarative and optional
- Execution options are handled separately from business logic inputs
This module defines the primary abstraction used by Falyx to expose structured,
composable workflows as CLI commands.
""" """
from __future__ import annotations from __future__ import annotations
import shlex import shlex
from typing import Any, Callable from typing import Any, Awaitable, Callable
from prompt_toolkit.formatted_text import FormattedText from prompt_toolkit.formatted_text import FormattedText
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
from rich.console import Console from rich.style import Style
from rich.tree import Tree from rich.tree import Tree
from falyx.action.action import Action, BaseAction from falyx.action.action import Action
from falyx.context import ExecutionContext from falyx.action.base_action import BaseAction
from falyx.console import console
from falyx.context import ExecutionContext, InvocationContext
from falyx.debug import register_debug_hooks from falyx.debug import register_debug_hooks
from falyx.exceptions import CommandArgumentError, InvalidHookError, NotAFalyxError
from falyx.execution_option import ExecutionOption
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType from falyx.hook_manager import HookManager, HookType
from falyx.hooks import spinner_before_hook, spinner_teardown_hook
from falyx.logger import logger from falyx.logger import logger
from falyx.options_manager import OptionsManager from falyx.options_manager import OptionsManager
from falyx.parsers.argparse import CommandArgumentParser from falyx.parser.command_argument_parser import CommandArgumentParser
from falyx.parsers.signature import infer_args_from_func from falyx.parser.signature import infer_args_from_func
from falyx.prompt_utils import confirm_async, should_prompt_user from falyx.prompt_utils import confirm_async, should_prompt_user
from falyx.protocols import ArgParserProtocol from falyx.protocols import ArgParserProtocol
from falyx.retry import RetryPolicy from falyx.retry import RetryPolicy
@@ -43,83 +71,126 @@ from falyx.signals import CancelSignal
from falyx.themes import OneColors from falyx.themes import OneColors
from falyx.utils import ensure_async from falyx.utils import ensure_async
console = Console(color_system="auto")
class Command(BaseModel): class Command(BaseModel):
""" """Represents a user-invokable command in Falyx.
Represents a selectable command in a Falyx menu system.
A Command wraps an executable action (function, coroutine, or BaseAction) A `Command` encapsulates all metadata, parsing logic, and execution behavior
and enhances it with: required to expose a callable workflow through the Falyx CLI or interactive
menu system.
- Lifecycle hooks (before, success, error, after, teardown) It is responsible for:
- Retry support (single action or recursive for chained/grouped actions) - Identifying the command via key and aliases
- Confirmation prompts for safe execution - Binding an executable Action or callable
- Spinner visuals during execution - Parsing user-provided arguments
- Tagging for categorization and filtering - Managing execution configuration (retries, confirmation, etc.)
- Rich-based CLI previews - Integrating with lifecycle hooks and execution context
Architecture:
- Parsing is delegated to CommandArgumentParser
- Execution is delegated to BaseAction / Action
- Runtime configuration is managed via OptionsManager
- Lifecycle hooks are managed via HookManager
Argument Handling:
- Supports positional and keyword arguments via CommandArgumentParser
- Separates execution-specific options (e.g. retries, confirm flags)
from action arguments
- Returns structured `(args, kwargs, execution_args)` for execution
Execution Behavior:
- Callable via `await command(*args, **kwargs)`
- Applies lifecycle hooks:
before → on_success/on_error → after → on_teardown
- Supports preview mode for dry-run introspection
- Supports retry policies and confirmation flows
- Result tracking and summary reporting - Result tracking and summary reporting
Commands are built to be flexible yet robust, enabling dynamic CLI workflows Help & Introspection:
without sacrificing control or reliability. - Provides usage, help text, and TLDR examples
- Supports both CLI help and interactive menu rendering
- Can expose simplified or full help signatures
Attributes: Args:
key (str): Primary trigger key for the command. key (str): Primary identifier used to invoke the command.
description (str): Short description for the menu display. description (str): Short description for the menu display.
hidden (bool): Toggles visibility in the menu. action (BaseAction | Callable[..., Any]):
aliases (list[str]): Alternate keys or phrases. Execution logic for the command.
action (BaseAction | Callable): The executable logic. args (tuple, optional): Static positional arguments.
args (tuple): Static positional arguments. kwargs (dict[str, Any], optional): Static keyword arguments.
kwargs (dict): Static keyword arguments. hidden (bool): Whether to hide the command from menus.
help_text (str): Additional help or guidance text. aliases (list[str], optional): Alternate names for invocation.
style (str): Rich style for description. help_text (str): Help description shown in CLI/menu.
confirm (bool): Whether to require confirmation before executing. help_epilog (str): Additional help content.
confirm_message (str): Custom confirmation prompt. style (Style | str): Rich style used for rendering.
preview_before_confirm (bool): Whether to preview before confirming. confirm (bool): Whether confirmation is required before execution.
spinner (bool): Whether to show a spinner during execution. confirm_message (str): Confirmation prompt text.
spinner_message (str): Spinner text message. preview_before_confirm (bool): Whether to preview before confirmation.
spinner_type (str): Spinner style (e.g., dots, line, etc.). spinner (bool): Enable spinner during execution.
spinner_style (str): Color or style of the spinner. spinner_message (str): Spinner message text.
spinner_kwargs (dict): Extra spinner configuration. spinner_type (str): Rich Spinner animation type (e.g., dots, line, etc.).
hooks (HookManager): Hook manager for lifecycle events. spinner_style (Style | str): Rich style for the spinner.
retry (bool): Enable retry on failure. spinner_speed (float): Spinner speed multiplier.
retry_all (bool): Enable retry across chained or grouped actions. hooks (HookManager | None): Hook manager for lifecycle events.
retry_policy (RetryPolicy): Retry behavior configuration. tags (list[str], optional): Tags for grouping and filtering.
tags (list[str]): Organizational tags for the command. logging_hooks (bool): Enable debug logging hooks.
logging_hooks (bool): Whether to attach logging hooks automatically. retry (bool): Enable retry behavior.
options_manager (OptionsManager): Manages global command-line options. retry_all (bool): Apply retry to all nested actions.
arg_parser (CommandArgumentParser): Parses command arguments. retry_policy (RetryPolicy | None): Retry configuration.
custom_parser (ArgParserProtocol | None): Custom argument parser. arg_parser (CommandArgumentParser | None):
custom_help (Callable[[], str | None] | None): Custom help message generator. Custom argument parser instance.
auto_args (bool): Automatically infer arguments from the action. execution_options (frozenset[ExecutionOption], optional):
Enabled execution-level options.
arguments (list[dict[str, Any]], optional):
Declarative argument definitions.
argument_config (Callable[[CommandArgumentParser], None] | None):
Callback to configure parser.
custom_parser (ArgParserProtocol | None):
Override parser logic entirely.
custom_help (Callable[[], str | None] | None):
Override help rendering.
custom_tldr (Callable[[], str | None] | None):
Override TLDR rendering.
custom_usage (Callable[[], str | None] | None):
Override usage rendering.
auto_args (bool): Auto-generate arguments from action signature.
arg_metadata (dict[str, Any], optional): Metadata for arguments.
simple_help_signature (bool): Use simplified help formatting.
ignore_in_history (bool):
Ignore command for `last_result` in execution history.
options_manager (OptionsManager | None):
Shared options manager instance.
program (str | None): The parent program name.
Methods: Raises:
__call__(): Executes the command, respecting hooks and retries. CommandArgumentError: If argument parsing fails.
preview(): Rich tree preview of the command. InvalidActionError: If action is not callable or invalid.
confirmation_prompt(): Formatted prompt for confirmation. FalyxError: If command configuration is invalid.
result: Property exposing the last result.
log_summary(): Summarizes execution details to the console. Notes:
- Commands are lightweight wrappers; execution logic belongs in Actions
- Argument parsing and execution are intentionally decoupled
- Commands are case-insensitive and support alias resolution
""" """
key: str key: str
description: str description: str
action: BaseAction | Callable[..., Any] action: BaseAction | Callable[..., Any] | Callable[..., Awaitable[Any]]
args: tuple = () args: tuple = ()
kwargs: dict[str, Any] = Field(default_factory=dict) kwargs: dict[str, Any] = Field(default_factory=dict)
hidden: bool = False hidden: bool = False
aliases: list[str] = Field(default_factory=list) aliases: list[str] = Field(default_factory=list)
help_text: str = "" help_text: str = ""
help_epilogue: str = "" help_epilog: str = ""
style: str = OneColors.WHITE style: Style | str = OneColors.WHITE
confirm: bool = False confirm: bool = False
confirm_message: str = "Are you sure?" confirm_message: str = "Are you sure?"
preview_before_confirm: bool = True preview_before_confirm: bool = True
spinner: bool = False spinner: bool = False
spinner_message: str = "Processing..." spinner_message: str = "Processing..."
spinner_type: str = "dots" spinner_type: str = "dots"
spinner_style: str = OneColors.CYAN spinner_style: Style | str = OneColors.CYAN
spinner_kwargs: dict[str, Any] = Field(default_factory=dict) spinner_speed: float = 1.0
hooks: "HookManager" = Field(default_factory=HookManager) hooks: "HookManager" = Field(default_factory=HookManager)
retry: bool = False retry: bool = False
retry_all: bool = False retry_all: bool = False
@@ -127,61 +198,127 @@ class Command(BaseModel):
tags: list[str] = Field(default_factory=list) tags: list[str] = Field(default_factory=list)
logging_hooks: bool = False logging_hooks: bool = False
options_manager: OptionsManager = Field(default_factory=OptionsManager) options_manager: OptionsManager = Field(default_factory=OptionsManager)
arg_parser: CommandArgumentParser = Field(default_factory=CommandArgumentParser) arg_parser: CommandArgumentParser | None = None
execution_options: frozenset[ExecutionOption] = Field(default_factory=frozenset)
arguments: list[dict[str, Any]] = Field(default_factory=list) arguments: list[dict[str, Any]] = Field(default_factory=list)
argument_config: Callable[[CommandArgumentParser], None] | None = None argument_config: Callable[[CommandArgumentParser], None] | None = None
custom_parser: ArgParserProtocol | None = None custom_parser: ArgParserProtocol | None = None
custom_help: Callable[[], str | None] | None = None custom_help: Callable[[], str | None] | None = None
custom_tldr: Callable[[], str | None] | None = None
custom_usage: Callable[[], str | None] | None = None
auto_args: bool = True auto_args: bool = True
arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict) arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict)
simple_help_signature: bool = False
ignore_in_history: bool = False
program: str | None = None
_context: ExecutionContext | None = PrivateAttr(default=None) _context: ExecutionContext | None = PrivateAttr(default=None)
model_config = ConfigDict(arbitrary_types_allowed=True) model_config = ConfigDict(arbitrary_types_allowed=True)
async def parse_args( async def resolve_args(
self, raw_args: list[str] | str, from_validate: bool = False self,
) -> tuple[tuple, dict]: raw_args: list[str] | str,
if callable(self.custom_parser): from_validate: bool = False,
invocation_context: InvocationContext | None = None,
) -> tuple[tuple, dict, dict]:
"""Parse CLI arguments into execution-ready components.
This method delegates argument parsing to the configured
CommandArgumentParser (if present) and normalizes the result into three
distinct groups used during execution:
- positional arguments (`args`)
- keyword arguments (`kwargs`)
- execution arguments (`execution_args`)
Execution arguments represent runtime configuration (e.g. retries,
confirmation flags, summary output) and are handled separately from the
action's business logic inputs.
Behavior:
- If an argument parser is defined, uses `CommandArgumentParser.parse_args_split()`
to resolve and type-coerce all inputs.
- If no parser is defined, returns empty args and kwargs.
- Supports validation mode (`from_validate=True`) for interactive input,
deferring certain errors and resolver execution where applicable.
- Handles help/preview signals raised during parsing.
Args:
args (list[str] | str | None): CLI-style argument tokens or a single string.
from_validate (bool): Whether parsing is occurring in validation mode
(e.g. prompt_toolkit validator). When True, may suppress eager
resolution or defer certain errors.
Returns:
tuple:
- tuple[Any, ...]: Positional arguments for execution.
- dict[str, Any]: Keyword arguments for execution.
- dict[str, Any]: Execution-specific arguments (e.g. retries,
confirm flags, summary).
Raises:
CommandArgumentError: If argument parsing or validation fails.
HelpSignal: If help or TLDR output is triggered during parsing.
Notes:
- Execution arguments are not passed to the underlying Action.
- This method is the canonical boundary between CLI parsing and
execution semantics.
"""
if self.custom_parser is not None:
if not callable(self.custom_parser):
raise NotAFalyxError(
"custom_parser must be a callable that implements ArgParserProtocol."
)
if isinstance(raw_args, str): if isinstance(raw_args, str):
try: try:
raw_args = shlex.split(raw_args) raw_args = shlex.split(raw_args)
except ValueError: except ValueError as error:
logger.warning( raise CommandArgumentError(
"[Command:%s] Failed to split arguments: %s", f"[{self.key}] Failed to parse arguments: {error}"
self.key, ) from error
raw_args,
)
return ((), {})
return self.custom_parser(raw_args) return self.custom_parser(raw_args)
if isinstance(raw_args, str): if isinstance(raw_args, str):
try: try:
raw_args = shlex.split(raw_args) raw_args = shlex.split(raw_args)
except ValueError: except ValueError as error:
logger.warning( raise CommandArgumentError(
"[Command:%s] Failed to split arguments: %s", f"[{self.key}] Failed to parse arguments: {error}"
self.key, ) from error
raw_args,
if self.arg_parser is None:
raise NotAFalyxError(
"Command has no parser configured. "
"Provide a custom_parser or CommandArgumentParser."
) )
return ((), {}) if not isinstance(self.arg_parser, CommandArgumentParser):
raise NotAFalyxError(
"arg_parser must be an instance of CommandArgumentParser"
)
return await self.arg_parser.parse_args_split( return await self.arg_parser.parse_args_split(
raw_args, from_validate=from_validate raw_args,
from_validate=from_validate,
invocation_context=invocation_context,
) )
@field_validator("action", mode="before") @field_validator("action", mode="before")
@classmethod @classmethod
def wrap_callable_as_async(cls, action: Any) -> Any: def _wrap_callable_as_async(cls, action: Any) -> Any:
if isinstance(action, BaseAction): if isinstance(action, BaseAction):
return action return action
elif callable(action): elif callable(action):
return ensure_async(action) return ensure_async(action)
raise TypeError("Action must be a callable or an instance of BaseAction") raise TypeError("Action must be a callable or an instance of BaseAction")
def get_argument_definitions(self) -> list[dict[str, Any]]: def _get_argument_definitions(self) -> list[dict[str, Any]]:
if self.arguments: if self.arguments:
return self.arguments return self.arguments
elif callable(self.argument_config): elif callable(self.argument_config) and isinstance(
self.arg_parser, CommandArgumentParser
):
self.argument_config(self.arg_parser) self.argument_config(self.arg_parser)
elif self.auto_args: elif self.auto_args:
if isinstance(self.action, BaseAction): if isinstance(self.action, BaseAction):
@@ -217,18 +354,70 @@ class Command(BaseModel):
if self.logging_hooks and isinstance(self.action, BaseAction): if self.logging_hooks and isinstance(self.action, BaseAction):
register_debug_hooks(self.action.hooks) register_debug_hooks(self.action.hooks)
for arg_def in self.get_argument_definitions(): if self.arg_parser is None and not self.custom_parser:
self.arg_parser = CommandArgumentParser(
command_key=self.key,
command_description=self.description,
command_style=self.style,
help_text=self.help_text,
help_epilog=self.help_epilog,
aliases=self.aliases,
program=self.program,
options_manager=self.options_manager,
)
for arg_def in self._get_argument_definitions():
self.arg_parser.add_argument(*arg_def.pop("flags"), **arg_def) self.arg_parser.add_argument(*arg_def.pop("flags"), **arg_def)
if isinstance(self.arg_parser, CommandArgumentParser) and self.execution_options:
self.arg_parser.enable_execution_options(self.execution_options)
if isinstance(self.arg_parser, CommandArgumentParser):
self.arg_parser.set_options_manager(self.options_manager)
if self.ignore_in_history and isinstance(self.action, BaseAction):
self.action.ignore_in_history = True
def _inject_options_manager(self) -> None: def _inject_options_manager(self) -> None:
"""Inject the options manager into the action if applicable.""" """Inject the options manager into the action if applicable."""
if isinstance(self.action, BaseAction): if isinstance(self.action, BaseAction):
self.action.set_options_manager(self.options_manager) self.action.set_options_manager(self.options_manager)
async def __call__(self, *args, **kwargs) -> Any: async def __call__(self, *args, **kwargs) -> Any:
""" """Execute the command's underlying action with lifecycle management.
Run the action with full hook lifecycle, timing, error handling,
confirmation prompts, preview, and spinner integration. This method invokes the bound action (BaseAction or callable) using the
provided arguments while applying the full Falyx execution lifecycle.
Execution Flow:
1. Create an ExecutionContext for tracking inputs, results, and timing
2. Trigger `before` hooks
3. Execute the underlying action
4. Trigger `on_success` or `on_error` hooks
5. Trigger `after` and `on_teardown` hooks
6. Record execution via ExecutionRegistry
Behavior:
- Supports both synchronous and asynchronous actions
- Applies retry policies if configured
- Integrates with confirmation and execution options via OptionsManager
- Propagates exceptions unless recovered by hooks (e.g. retry handlers)
Args:
*args (Any): Positional arguments passed to the action.
**kwargs (Any): Keyword arguments passed to the action.
Returns:
Any: Result returned by the underlying action.
Raises:
Exception: Propagates execution errors unless handled by hooks.
Notes:
- This method does not perform argument parsing; inputs are assumed
to be pre-processed via `resolve_args`.
- Execution options (e.g. retries, confirm) are applied externally
via Falyx in OptionsManager before invocation.
- Lifecycle hooks are always executed, even in failure cases.
""" """
self._inject_options_manager() self._inject_options_manager()
combined_args = args + self.args combined_args = args + self.args
@@ -244,23 +433,14 @@ class Command(BaseModel):
if should_prompt_user(confirm=self.confirm, options=self.options_manager): if should_prompt_user(confirm=self.confirm, options=self.options_manager):
if self.preview_before_confirm: if self.preview_before_confirm:
await self.preview() await self.preview()
if not await confirm_async(self.confirmation_prompt): if not await confirm_async(self._confirmation_prompt):
logger.info("[Command:%s] Cancelled by user.", self.key) logger.info("[Command:%s] Cancelled by user.", self.key)
raise CancelSignal(f"[Command:{self.key}] Cancelled by confirmation.") raise CancelSignal(f"[Command:{self.key}] Cancelled by confirmation.")
context.start_timer() context.start_timer()
try: try:
await self.hooks.trigger(HookType.BEFORE, context) await self.hooks.trigger(HookType.BEFORE, context)
if self.spinner:
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) result = await self.action(*combined_args, **combined_kwargs)
context.result = result context.result = result
@@ -282,7 +462,7 @@ class Command(BaseModel):
return self._context.result if self._context else None return self._context.result if self._context else None
@property @property
def confirmation_prompt(self) -> FormattedText: def _confirmation_prompt(self) -> FormattedText:
"""Generate a styled prompt_toolkit FormattedText confirmation message.""" """Generate a styled prompt_toolkit FormattedText confirmation message."""
if self.confirm_message and self.confirm_message != "Are you sure?": if self.confirm_message and self.confirm_message != "Are you sure?":
return FormattedText([("class:confirm", self.confirm_message)]) return FormattedText([("class:confirm", self.confirm_message)])
@@ -306,11 +486,93 @@ class Command(BaseModel):
return FormattedText(prompt) return FormattedText(prompt)
@property
def primary_alias(self) -> str:
"""Get the primary alias for the command, used in help displays."""
if self.aliases:
return self.aliases[0].lower()
return self.key
@property
def usage(self) -> str:
"""Generate a help string for the command arguments."""
if not self.arg_parser:
return "No arguments defined."
command_keys_text = self.arg_parser.get_command_keys_text()
options_text = self.arg_parser.get_options_text()
return f" {command_keys_text:<20} {options_text} "
@property
def help_signature(
self,
invocation_context: InvocationContext | None = None,
) -> tuple[str, str, str]:
"""Return a formatted help signature for display.
This property provides the core information used to render command help
in both CLI and interactive menu modes.
The signature consists of:
- usage: A formatted usage string (including arguments if defined)
- description: A short description of the command
- tag: Optional tag or category label (if applicable)
Behavior:
- If a CommandArgumentParser is present, delegates usage generation to
the parser (`get_usage()`).
- Otherwise, constructs a minimal usage string from the command key.
- Honors `simple_help_signature` to produce a condensed representation
(e.g. omitting argument details).
- Applies styling appropriate for Rich rendering.
Returns:
tuple:
- str: Usage string (e.g. "falyx D | deploy [--help] region")
- str: Command description
- str: Optional tag/category label
Notes:
- This is the primary interface used by help menus, CLI help output,
and command listings.
- Formatting may vary depending on CLI vs menu mode.
"""
if self.arg_parser and not self.simple_help_signature:
usage = self.arg_parser.get_usage(invocation_context)
description = f"[dim]{self.help_text or self.description}[/dim]"
if self.tags:
tags = f"[dim]Tags: {', '.join(self.tags)}[/dim]"
else:
tags = ""
return usage, description, tags
command_keys = " | ".join(
[f"[{self.style}]{self.key}[/{self.style}]"]
+ [f"[{self.style}]{alias}[/{self.style}]" for alias in self.aliases]
)
return (
f"{command_keys}",
f"[dim]{self.help_text or self.description}[/dim]",
"",
)
def log_summary(self) -> None: def log_summary(self) -> None:
if self._context: if self._context:
self._context.log_summary() self._context.log_summary()
def show_help(self) -> bool: def render_usage(self, invocation_context: InvocationContext | None = None) -> None:
"""Render the usage information for the command."""
if callable(self.custom_usage):
output = self.custom_usage()
if output:
console.print(output)
return
if isinstance(self.arg_parser, CommandArgumentParser):
self.arg_parser.render_usage(invocation_context)
else:
console.print(f"[bold]usage:[/] {self.key}")
def render_help(self, invocation_context: InvocationContext | None = None) -> bool:
"""Display the help message for the command.""" """Display the help message for the command."""
if callable(self.custom_help): if callable(self.custom_help):
output = self.custom_help() output = self.custom_help()
@@ -318,7 +580,19 @@ class Command(BaseModel):
console.print(output) console.print(output)
return True return True
if isinstance(self.arg_parser, CommandArgumentParser): if isinstance(self.arg_parser, CommandArgumentParser):
self.arg_parser.render_help() self.arg_parser.render_help(invocation_context)
return True
return False
def render_tldr(self, invocation_context: InvocationContext | None = None) -> bool:
"""Display the TLDR message for the command."""
if callable(self.custom_tldr):
output = self.custom_tldr()
if output:
console.print(output)
return True
if isinstance(self.arg_parser, CommandArgumentParser):
self.arg_parser.render_tldr(invocation_context)
return True return True
return False return False
@@ -352,3 +626,232 @@ class Command(BaseModel):
f"Command(key='{self.key}', description='{self.description}' " f"Command(key='{self.key}', description='{self.description}' "
f"action='{self.action}')" f"action='{self.action}')"
) )
@classmethod
def build(
cls,
key: str,
description: str,
action: BaseAction | Callable[..., Any],
*,
args: tuple = (),
kwargs: dict[str, Any] | None = None,
hidden: bool = False,
aliases: list[str] | None = None,
help_text: str = "",
help_epilog: str = "",
style: Style | str = OneColors.WHITE,
confirm: bool = False,
confirm_message: str = "Are you sure?",
preview_before_confirm: bool = True,
spinner: bool = False,
spinner_message: str = "Processing...",
spinner_type: str = "dots",
spinner_style: Style | str = OneColors.CYAN,
spinner_speed: float = 1.0,
options_manager: OptionsManager | None = None,
hooks: HookManager | None = None,
before_hooks: list[Callable] | None = None,
success_hooks: list[Callable] | None = None,
error_hooks: list[Callable] | None = None,
after_hooks: list[Callable] | None = None,
teardown_hooks: list[Callable] | None = None,
tags: list[str] | None = None,
logging_hooks: bool = False,
retry: bool = False,
retry_all: bool = False,
retry_policy: RetryPolicy | None = None,
arg_parser: CommandArgumentParser | None = None,
arguments: list[dict[str, Any]] | None = None,
argument_config: Callable[[CommandArgumentParser], None] | None = None,
execution_options: list[ExecutionOption | str] | None = None,
custom_parser: ArgParserProtocol | None = None,
custom_help: Callable[[], str | None] | None = None,
custom_tldr: Callable[[], str | None] | None = None,
custom_usage: Callable[[], str | None] | None = None,
auto_args: bool = True,
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
simple_help_signature: bool = False,
ignore_in_history: bool = False,
program: str | None = None,
) -> Command:
"""Build and configure a `Command` instance from high-level constructor inputs.
This factory centralizes command construction so callers such as `Falyx` and
`CommandRunner` can create fully configured commands through one consistent
path. It normalizes optional inputs, validates selected objects, converts
execution options into their canonical internal form, and registers any
requested command-level hooks.
In addition to instantiating the `Command`, this method can:
- validate and attach an explicit `CommandArgumentParser`
- normalize execution options into a `frozenset[ExecutionOption]`
- ensure a shared `OptionsManager` is available
- attach a custom `HookManager`
- register lifecycle hooks for the command
- register spinner hooks when spinner support is enabled
Args:
key (str): Primary identifier used to invoke the command.
description (str): Short description of the command.
action (BaseAction | Callable[..., Any]): Underlying execution logic for
the command.
args (tuple): Static positional arguments applied to every execution.
kwargs (dict[str, Any] | None): Static keyword arguments applied to every
execution.
hidden (bool): Whether the command should be hidden from menu displays.
aliases (list[str] | None): Optional alternate names for invocation.
help_text (str): Help text shown in command help output.
help_epilog (str): Additional help text shown after the main help body.
style (Style | str): Rich style used when rendering the command.
confirm (bool): Whether confirmation is required before execution.
confirm_message (str): Confirmation prompt text.
preview_before_confirm (bool): Whether to preview before confirmation.
spinner (bool): Whether to enable spinner lifecycle hooks.
spinner_message (str): Spinner message text.
spinner_type (str): Spinner animation type.
spinner_style (Style | str): Spinner style.
spinner_speed (float): Spinner speed multiplier.
options_manager (OptionsManager | None): Shared options manager for the
command and its parser.
hooks (HookManager | None): Optional hook manager to assign directly to the
command.
before_hooks (list[Callable] | None): Hooks registered for the `BEFORE`
lifecycle stage.
success_hooks (list[Callable] | None): Hooks registered for the
`ON_SUCCESS` lifecycle stage.
error_hooks (list[Callable] | None): Hooks registered for the `ON_ERROR`
lifecycle stage.
after_hooks (list[Callable] | None): Hooks registered for the `AFTER`
lifecycle stage.
teardown_hooks (list[Callable] | None): Hooks registered for the
`ON_TEARDOWN` lifecycle stage.
tags (list[str] | None): Optional tags used for grouping and filtering.
logging_hooks (bool): Whether to enable debug hook logging.
retry (bool): Whether retry behavior is enabled.
retry_all (bool): Whether retry behavior should be applied recursively.
retry_policy (RetryPolicy | None): Retry configuration for the command.
arg_parser (CommandArgumentParser | None): Optional explicit argument
parser instance.
arguments (list[dict[str, Any]] | None): Declarative argument
definitions for the command parser.
argument_config (Callable[[CommandArgumentParser], None] | None): Callback
used to configure the argument parser.
execution_options (list[ExecutionOption | str] | None): Execution-level
options to enable for the command.
custom_parser (ArgParserProtocol | None): Optional custom parser
implementation that overrides normal parser behavior.
custom_help (Callable[[], str | None] | None): Optional custom help
renderer.
custom_tldr (Callable[[], str | None] | None): Optional custom TLDR
renderer.
custom_usage (Callable[[], str | None] | None): Optional custom usage
renderer.
auto_args (bool): Whether to infer arguments automatically from the action
signature when explicit definitions are not provided.
arg_metadata (dict[str, str | dict[str, Any]] | None): Optional metadata
used during argument inference.
simple_help_signature (bool): Whether to use a simplified help signature.
ignore_in_history (bool): Whether to exclude the command from execution
history tracking.
program (str | None): Parent program name used in help rendering.
Returns:
Command: A fully configured `Command` instance.
Raises:
NotAFalyxError: If `arg_parser` is provided but is not a
`CommandArgumentParser` instance.
InvalidHookError: If `hooks` is provided but is not a `HookManager` instance.
Notes:
- Execution options supplied as strings are converted to
`ExecutionOption` enum values before the command is created.
- If no `options_manager` is provided, a new `OptionsManager` is created.
- Spinner hooks are registered at build time when `spinner=True`.
- This method is the canonical command-construction path used by higher-
level APIs such as `Falyx.add_command()` and `CommandRunner.build()`.
"""
if arg_parser and not isinstance(arg_parser, CommandArgumentParser):
raise NotAFalyxError(
"arg_parser must be an instance of CommandArgumentParser."
)
arg_parser = arg_parser
if options_manager and not isinstance(options_manager, OptionsManager):
raise NotAFalyxError("options_manager must be an instance of OptionsManager.")
options_manager = options_manager or OptionsManager()
if hooks and not isinstance(hooks, HookManager):
raise InvalidHookError("hooks must be an instance of HookManager.")
hooks = hooks or HookManager()
if retry_policy and not isinstance(retry_policy, RetryPolicy):
raise NotAFalyxError("retry_policy must be an instance of RetryPolicy.")
retry_policy = retry_policy or RetryPolicy()
if execution_options:
parsed_execution_options = frozenset(
ExecutionOption(option) if isinstance(option, str) else option
for option in execution_options
)
else:
parsed_execution_options = frozenset()
command = Command(
key=key,
description=description,
action=action,
args=args,
kwargs=kwargs if kwargs else {},
hidden=hidden,
aliases=aliases if aliases else [],
help_text=help_text,
help_epilog=help_epilog,
style=style,
confirm=confirm,
confirm_message=confirm_message,
preview_before_confirm=preview_before_confirm,
spinner=spinner,
spinner_message=spinner_message,
spinner_type=spinner_type,
spinner_style=spinner_style,
spinner_speed=spinner_speed,
tags=tags if tags else [],
logging_hooks=logging_hooks,
hooks=hooks,
retry=retry,
retry_all=retry_all,
retry_policy=retry_policy,
options_manager=options_manager,
arg_parser=arg_parser,
execution_options=parsed_execution_options,
arguments=arguments or [],
argument_config=argument_config,
custom_parser=custom_parser,
custom_help=custom_help,
custom_tldr=custom_tldr,
custom_usage=custom_usage,
auto_args=auto_args,
arg_metadata=arg_metadata or {},
simple_help_signature=simple_help_signature,
ignore_in_history=ignore_in_history,
program=program,
)
for hook in before_hooks or []:
command.hooks.register(HookType.BEFORE, hook)
for hook in success_hooks or []:
command.hooks.register(HookType.ON_SUCCESS, hook)
for hook in error_hooks or []:
command.hooks.register(HookType.ON_ERROR, hook)
for hook in after_hooks or []:
command.hooks.register(HookType.AFTER, hook)
for hook in teardown_hooks or []:
command.hooks.register(HookType.ON_TEARDOWN, hook)
if spinner:
command.hooks.register(HookType.BEFORE, spinner_before_hook)
command.hooks.register(HookType.ON_TEARDOWN, spinner_teardown_hook)
return command

311
falyx/command_executor.py Normal file
View File

@@ -0,0 +1,311 @@
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Shared command execution engine for the Falyx CLI framework.
This module defines `CommandExecutor`, a low-level execution service responsible
for running already-resolved `Command` objects with a consistent outer lifecycle.
`CommandExecutor` sits between higher-level orchestration layers (such as
`Falyx.execute_command()` or `CommandRunner.run()`) and the command itself.
It does not perform command lookup or argument parsing. Instead, it accepts a
resolved `Command` plus prepared `args`, `kwargs`, and `execution_args`, then
applies executor-level behavior around the command invocation.
Responsibilities:
- Apply execution-scoped runtime overrides such as confirmation flags
- Apply retry overrides from execution arguments
- Trigger executor-level lifecycle hooks
- Create and manage an outer `ExecutionContext`
- Delegate actual invocation to the resolved `Command`
- Handle interruption and failure policies
- Optionally print execution summaries via `ExecutionRegistry`
Execution Model:
1. A command is resolved and its arguments are prepared elsewhere.
2. Retry and execution-option overrides are derived from `execution_args`.
3. An outer `ExecutionContext` is created for executor-level tracking.
4. Executor hooks are triggered around the command invocation.
5. The command is executed inside an `OptionsManager.override_namespace()`
context for scoped runtime overrides.
6. Errors are either surfaced, wrapped, or rendered depending on the
configured execution policy.
7. Optional summary output is emitted after execution completes.
Design Notes:
- `CommandExecutor` is intentionally narrower in scope than `Falyx`.
It does not resolve commands, parse raw CLI text, or manage menu state.
- `Command` still owns command-local behavior such as confirmation,
command hooks, and delegation to the underlying `Action`.
- This module exists to centralize shared execution behavior and reduce
duplication across Falyx runtime entrypoints.
Typical Usage:
executor = CommandExecutor(options=options, hooks=hooks)
result = await executor.execute(
command=command,
args=args,
kwargs=kwargs,
execution_args=execution_args,
)
"""
from __future__ import annotations
from typing import Any
from falyx.action import Action
from falyx.command import Command
from falyx.context import ExecutionContext
from falyx.exceptions import FalyxError
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType
from falyx.logger import logger
from falyx.options_manager import OptionsManager
class CommandExecutor:
"""Execute resolved Falyx commands with shared outer lifecycle handling.
`CommandExecutor` provides a reusable execution service for running a
`Command` after command resolution and argument parsing have already been
completed.
This class is intended to be shared by higher-level entrypoints such as
`Falyx` and `CommandRunner`. It centralizes the outer execution flow so
command execution semantics remain consistent across menu-driven and
programmatic use cases.
Responsibilities:
- Apply retry overrides from execution arguments
- Apply scoped runtime overrides using `OptionsManager`
- Trigger executor-level hooks before and after command execution
- Create and manage an executor-level `ExecutionContext`
- Control whether errors are raised or wrapped
- Emit optional execution summaries
Attributes:
options (OptionsManager): Shared options manager used to apply scoped
execution overrides.
hooks (HookManager): Hook manager for executor-level lifecycle hooks.
"""
def __init__(
self,
*,
options: OptionsManager,
hooks: HookManager,
) -> None:
self.options = options
self.hooks = hooks
def _debug_hooks(self, command: Command) -> None:
"""Log executor-level and command-level hook registrations for debugging.
This helper is used to surface the currently registered hooks on both the
executor and the resolved command before execution begins.
Args:
command (Command): The command about to be executed.
"""
logger.debug("executor hooks:\n%s", str(self.hooks))
logger.debug("['%s'] hooks:\n%s", command.key, str(command.hooks))
def _apply_retry_overrides(
self,
command: Command,
execution_args: dict[str, Any],
) -> None:
"""Apply retry-related execution overrides to the command.
This method inspects execution-level retry options and updates the
command's retry policy in place when overrides are provided. If the
command's underlying action is an `Action`, the updated retry policy is
propagated to that action as well.
Args:
command (Command): The command whose retry policy may be updated.
execution_args (dict[str, Any]): Execution-level arguments that may
contain retry overrides such as `retries`, `retry_delay`, and
`retry_backoff`.
Notes:
- If no retry-related overrides are provided, this method does nothing.
- If the command action is not an `Action`, a warning is logged and the
command-level retry policy is updated without propagating it further.
"""
retries = execution_args.get("retries", 0)
retry_delay = execution_args.get("retry_delay", 0.0)
retry_backoff = execution_args.get("retry_backoff", 0.0)
logger.debug(
"[_apply_retry_overrides]: retries=%s, retry_delay=%s, retry_backoff=%s",
retries,
retry_delay,
retry_backoff,
)
if not retries and not retry_delay and not retry_backoff:
return
command.retry_policy.enabled = True
if retries:
command.retry_policy.max_retries = retries
if retry_delay:
command.retry_policy.delay = retry_delay
if retry_backoff:
command.retry_policy.backoff = retry_backoff
if isinstance(command.action, Action):
command.action.set_retry_policy(command.retry_policy)
else:
logger.warning(
"[%s] Retry requested, but action is not an Action instance.",
command.key,
)
def _execution_option_overrides(
self,
execution_args: dict[str, Any],
) -> dict[str, Any]:
"""Build scoped option overrides from execution arguments.
This method extracts execution-only runtime flags that should be applied
through the `OptionsManager` during command execution.
Args:
execution_args (dict[str, Any]): Execution-level arguments returned
from command argument resolution.
Returns:
dict[str, Any]: Mapping of option names to temporary execution-scoped
override values.
"""
return {
"force_confirm": execution_args.get("force_confirm", False),
"skip_confirm": execution_args.get("skip_confirm", False),
}
async def execute(
self,
*,
command: Command,
args: tuple,
kwargs: dict[str, Any],
execution_args: dict[str, Any],
raise_on_error: bool = True,
wrap_errors: bool = False,
summary_last_result: bool = False,
) -> Any:
"""Execute a resolved command with executor-level lifecycle management.
This method is the primary entrypoint of `CommandExecutor`. It accepts an
already-resolved `Command` and its prepared execution inputs, then applies
shared outer execution behavior around the command invocation.
Execution Flow:
1. Log currently registered hooks for debugging.
2. Apply retry overrides from `execution_args`.
3. Derive scoped runtime overrides for the execution namespace.
4. Create and start an outer `ExecutionContext`.
5. Trigger executor-level `BEFORE` hooks.
6. Execute the command inside an execution-scoped options override
context.
7. Trigger executor-level `SUCCESS` or `ERROR` hooks.
8. Trigger `AFTER` and `ON_TEARDOWN` hooks.
9. Optionally print an execution summary.
Args:
command (Command): The resolved command to execute.
args (tuple): Positional arguments to pass to the command.
kwargs (dict[str, Any]): Keyword arguments to pass to the command.
execution_args (dict[str, Any]): Execution-only arguments that affect
runtime behavior, such as retry or confirmation overrides.
raise_on_error (bool): Whether execution errors should be re-raised
after handling.
wrap_errors (bool): Whether handled errors should be wrapped in a
`FalyxError` before being raised.
summary_last_result (bool): Whether summary output should only have the
last recorded result when summary reporting is enabled.
Returns:
Any: The result returned by the command, or any recovered result
attached to the execution context.
Raises:
KeyboardInterrupt: If execution is interrupted by the user and
`raise_on_error` is True and `wrap_errors` is False.
EOFError: If execution receives EOF interruption and `raise_on_error`
is True and `wrap_errors` is False.
FalyxError: If `wrap_errors` is True and execution is interrupted or
fails.
Exception: Re-raises the underlying execution error when
`raise_on_error` is True and `wrap_errors` is False.
Notes:
- This method assumes the command has already been resolved and its
arguments have already been parsed.
- Command-local behavior, such as confirmation prompts and command hook
execution, remains the responsibility of `Command.__call__()`.
- Summary output is only emitted when the `summary` execution option is
present in `execution_args`.
"""
if not (raise_on_error or wrap_errors):
raise FalyxError(
"CommandExecutor.execute() requires either raise_on_error=True "
"or wrap_errors=True."
)
self._debug_hooks(command)
self._apply_retry_overrides(command, execution_args)
overrides = self._execution_option_overrides(execution_args)
context = ExecutionContext(
name=command.description,
args=args,
kwargs=kwargs,
action=command,
)
logger.info(
"[execute] Starting execution of '%s' with args: %s, kwargs: %s",
command.description,
args,
kwargs,
)
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
with self.options.override_namespace(
overrides=overrides,
namespace_name="execution",
):
result = await command(*args, **kwargs)
context.result = result
await self.hooks.trigger(HookType.ON_SUCCESS, context)
except (KeyboardInterrupt, EOFError) as error:
logger.info(
"[execute] '%s' interrupted by user.",
command.key,
)
if wrap_errors:
raise FalyxError(
f"[execute] '{command.key}' interrupted by user."
) from error
raise error
except Exception as error:
logger.debug(
"[execute] '%s' failed: %s",
command.key,
error,
exc_info=True,
)
context.exception = error
await self.hooks.trigger(HookType.ON_ERROR, context)
if wrap_errors:
raise FalyxError(f"[execute] '{command.key}' failed: {error}") from error
raise error
finally:
context.stop_timer()
await self.hooks.trigger(HookType.AFTER, context)
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
if execution_args.get("summary") and summary_last_result:
er.summary(last_result=True)
elif execution_args.get("summary"):
er.summary()
return context.result

531
falyx/command_runner.py Normal file
View File

@@ -0,0 +1,531 @@
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Standalone command runner for the Falyx CLI framework.
This module defines `CommandRunner`, a developer-facing convenience wrapper for
executing a single `Command` outside the full `Falyx` runtime.
`CommandRunner` is designed for programmatic and standalone command execution
where command lookup, menu interaction, and root CLI parsing are not needed.
It provides a small, focused API that:
- owns a single `Command`
- ensures the command and parser share a consistent `OptionsManager`
- delegates shared execution behavior to `CommandExecutor`
- supports both wrapping an existing `Command` and building one from raw
constructor-style arguments
Responsibilities:
- Hold a single resolved `Command` for repeated execution
- Normalize runtime dependencies such as `OptionsManager`, `HookManager`,
and `Console`
- Resolve command arguments from raw argv-style input
- Delegate execution to `CommandExecutor` for shared outer lifecycle
handling
Design Notes:
- `CommandRunner` is intentionally narrower than `Falyx`.
It does not resolve commands by name, render menus, or manage built-ins.
- `CommandExecutor` remains the shared execution core.
`CommandRunner` exists as a convenience layer for developer-facing and
standalone use cases.
- `Command` still owns command-local behavior such as confirmation,
command hook execution, and delegation to the underlying `Action`.
Typical Usage:
runner = CommandRunner.from_command(existing_command)
result = await runner.run(["--region", "us-east"])
#!/usr/bin/env python
import asyncio
runner = CommandRunner.build(
key="D",
description="Deploy",
action=deploy,
)
result = asyncio.run(runner.cli())
$ ./deploy.py --region us-east
"""
from __future__ import annotations
import asyncio
import sys
from typing import Any, Callable
from rich.console import Console
from falyx.action import BaseAction
from falyx.command import Command
from falyx.command_executor import CommandExecutor
from falyx.console import console as falyx_console
from falyx.console import error_console, print_error
from falyx.exceptions import (
CommandArgumentError,
FalyxError,
InvalidHookError,
NotAFalyxError,
)
from falyx.execution_option import ExecutionOption
from falyx.hook_manager import HookManager
from falyx.logger import logger
from falyx.options_manager import OptionsManager
from falyx.parser.command_argument_parser import CommandArgumentParser
from falyx.protocols import ArgParserProtocol
from falyx.retry import RetryPolicy
from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
from falyx.themes import OneColors
class CommandRunner:
"""Run a single Falyx command outside the full Falyx application runtime.
`CommandRunner` is a lightweight wrapper around a single `Command` plus a
`CommandExecutor`. It is intended for standalone execution, testing, and
developer-facing programmatic usage where command resolution has already
happened or is unnecessary.
This class is responsible for:
- storing the bound `Command`
- providing a shared `OptionsManager` to the command and its parser
- exposing a simple `run()` method that accepts argv-style input
- delegating shared execution behavior to `CommandExecutor`
Attributes:
command (Command): The command executed by this runner.
program (str): Program name used in CLI usage text and help output.
options (OptionsManager): Shared options manager used by the command,
parser, and executor.
runner_hooks (HookManager): Executor-level hooks used during execution.
console (Console): Rich console used for user-facing output.
executor (CommandExecutor): Shared execution engine used to run the
bound command.
"""
def __init__(
self,
command: Command,
*,
program: str | None = None,
options: OptionsManager | None = None,
runner_hooks: HookManager | None = None,
console: Console | None = None,
) -> None:
"""Initialize a `CommandRunner` for a single command.
The runner ensures that the bound command, its argument parser, and the
internal `CommandExecutor` all share the same `OptionsManager` and runtime
dependencies.
Args:
command (Command): The command to execute.
program (str | None): Program name used in CLI usage text, invocation-path
rendering, and built-in help output. If `None`, an empty program name is
used.
options (OptionsManager | None): Optional shared options manager. If
omitted, a new `OptionsManager` is created.
runner_hooks (HookManager | None): Optional executor-level hook manager. If
omitted, a new `HookManager` is created.
console (Console | None): Optional Rich console for output. If omitted,
the default Falyx console is used.
"""
self.command = command
self.program = program or ""
self.options = self._get_options(options)
self.runner_hooks = self._get_hooks(runner_hooks)
self.console = self._get_console(console)
self.error_console = error_console
self.command.options_manager = self.options
if program:
self.command.program = program
if isinstance(self.command.arg_parser, CommandArgumentParser):
self.command.arg_parser.set_options_manager(self.options)
self.command.arg_parser.is_runner_mode = True
if program:
self.command.arg_parser.program = program
self.executor = CommandExecutor(
options=self.options,
hooks=self.runner_hooks,
)
self.options.from_mapping(values={}, namespace_name="execution")
def _get_console(self, console) -> Console:
if console is None:
return falyx_console
elif isinstance(console, Console):
return console
else:
raise NotAFalyxError("console must be an instance of rich.Console or None.")
def _get_options(self, options) -> OptionsManager:
if options is None:
return OptionsManager()
elif isinstance(options, OptionsManager):
return options
else:
raise NotAFalyxError("options must be an instance of OptionsManager or None.")
def _get_hooks(self, hooks) -> HookManager:
if hooks is None:
return HookManager()
elif isinstance(hooks, HookManager):
return hooks
else:
raise InvalidHookError("hooks must be an instance of HookManager or None.")
async def run(
self,
argv: list[str] | str | None = None,
raise_on_error: bool = True,
wrap_errors: bool = False,
summary_last_result: bool = False,
) -> Any:
"""Resolve arguments and execute the bound command.
This method is the primary execution entrypoint for `CommandRunner`. It
accepts raw argv-style tokens, resolves them into positional arguments,
keyword arguments, and execution arguments via `Command.resolve_args()`,
then delegates execution to the internal `CommandExecutor`.
Args:
argv (list[str] | str | None): Optional argv-style argument tokens or
string (uses `shlex.split()` if a string is provided). If omitted,
`sys.argv[1:]` is used.
Returns:
Any: The result returned by the bound command.
Raises:
Exception: Propagates any execution error surfaced by the underlying
`CommandExecutor` or command execution path.
"""
argv = sys.argv[1:] if argv is None else argv
args, kwargs, execution_args = await self.command.resolve_args(argv)
logger.debug(
"Executing command '%s' with args=%s, kwargs=%s, execution_args=%s",
self.command.description,
args,
kwargs,
execution_args,
)
return await self.executor.execute(
command=self.command,
args=args,
kwargs=kwargs,
execution_args=execution_args,
raise_on_error=raise_on_error,
wrap_errors=wrap_errors,
summary_last_result=summary_last_result,
)
async def cli(
self,
argv: list[str] | str | None = None,
summary_last_result: bool = False,
) -> Any:
"""Run the bound command as a shell-oriented CLI entrypoint.
This method wraps `run()` with command-line specific behavior. It executes the
bound command using raw argv-style input, then translates framework signals and
execution failures into user-facing console output and process exit codes.
Unlike `run()`, this method is intended for direct CLI usage rather than
programmatic integration. It may terminate the current process via `sys.exit()`.
Behavior:
- Delegates normal execution to `run()`
- Exits with status code `0` when help output is requested
- Exits with status code `2` for command argument or usage errors
- Exits with status code `1` for execution failures and non-success control
flow such as cancellation or back-navigation
- Exits with status code `130` for quit/interrupt-style termination
Args:
argv (list[str] | str | None): Optional argv-style argument tokens or string
(uses `shlex.split()` if a string is provided). If omitted, `sys.argv[1:]`
is used by `run()`.
summary_last_result (bool): Whether summary output should include the last
recorded result when summary reporting is enabled.
Returns:
Any: The result returned by the bound command when execution completes
successfully.
Raises:
SystemExit: Always raised for handled CLI exit paths, including help,
argument errors, cancellations, and execution failures.
Notes:
- This method is intentionally shell-facing and should be used in
script entrypoints such as `asyncio.run(runner.cli())`.
- For programmatic use, prefer `run()`, which preserves normal Python
exception behavior and does not call `sys.exit()`.
"""
try:
return await self.run(
argv=argv,
raise_on_error=False,
wrap_errors=True,
summary_last_result=summary_last_result,
)
except HelpSignal:
sys.exit(0)
except CommandArgumentError as error:
self.command.render_help()
print_error(message=error)
sys.exit(2)
except FalyxError as error:
print_error(message=error)
sys.exit(1)
except QuitSignal:
logger.info("[QuitSignal]. <- Exiting run.")
sys.exit(130)
except BackSignal:
logger.info("[BackSignal]. <- Exiting run.")
sys.exit(1)
except CancelSignal:
logger.info("[CancelSignal]. <- Exiting run.")
sys.exit(1)
except asyncio.CancelledError:
logger.info("[asyncio.CancelledError]. <- Exiting run.")
sys.exit(1)
@classmethod
def from_command(
cls,
command: Command,
*,
program: str | None = None,
runner_hooks: HookManager | None = None,
options: OptionsManager | None = None,
console: Console | None = None,
) -> CommandRunner:
"""Create a `CommandRunner` from an existing `Command` instance.
This factory is useful when a command has already been defined elsewhere
and should be exposed through the standalone runner interface without
rebuilding it.
Args:
command (Command): Existing command instance to wrap.
program (str | None): Program name used in CLI usage text, invocation-path
rendering, and built-in help output. If `None`, an empty program name is
used.
runner_hooks (HookManager | None): Optional executor-level hook manager
for the runner.
options (OptionsManager | None): Optional shared options manager.
console (Console | None): Optional Rich console for output.
Returns:
CommandRunner: A runner bound to the provided command.
Raises:
NotAFalyxError: If `runner_hooks` is provided but is not a
`HookManager` instance.
"""
if not isinstance(command, Command):
raise NotAFalyxError("command must be an instance of Command.")
if runner_hooks and not isinstance(runner_hooks, HookManager):
raise InvalidHookError("runner_hooks must be an instance of HookManager.")
return cls(
command=command,
program=program,
options=options,
runner_hooks=runner_hooks,
console=console,
)
@classmethod
def build(
cls,
key: str,
description: str,
action: BaseAction | Callable[..., Any],
*,
program: str | None = None,
runner_hooks: HookManager | None = None,
args: tuple = (),
kwargs: dict[str, Any] | None = None,
hidden: bool = False,
aliases: list[str] | None = None,
help_text: str = "",
help_epilog: str = "",
style: str = OneColors.WHITE,
confirm: bool = False,
confirm_message: str = "Are you sure?",
preview_before_confirm: bool = True,
spinner: bool = False,
spinner_message: str = "Processing...",
spinner_type: str = "dots",
spinner_style: str = OneColors.CYAN,
spinner_speed: float = 1.0,
options: OptionsManager | None = None,
command_hooks: HookManager | None = None,
before_hooks: list[Callable] | None = None,
success_hooks: list[Callable] | None = None,
error_hooks: list[Callable] | None = None,
after_hooks: list[Callable] | None = None,
teardown_hooks: list[Callable] | None = None,
tags: list[str] | None = None,
logging_hooks: bool = False,
retry: bool = False,
retry_all: bool = False,
retry_policy: RetryPolicy | None = None,
arg_parser: CommandArgumentParser | None = None,
arguments: list[dict[str, Any]] | None = None,
argument_config: Callable[[CommandArgumentParser], None] | None = None,
execution_options: list[ExecutionOption | str] | None = None,
custom_parser: ArgParserProtocol | None = None,
custom_help: Callable[[], str | None] | None = None,
custom_tldr: Callable[[], str | None] | None = None,
custom_usage: Callable[[], str | None] | None = None,
auto_args: bool = True,
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
simple_help_signature: bool = False,
ignore_in_history: bool = False,
console: Console | None = None,
) -> CommandRunner:
"""Build a `Command` and wrap it in a `CommandRunner`.
This factory is a convenience constructor for standalone usage. It mirrors
the high-level command-building API by creating a configured `Command`
through `Command.build()` and then returning a `CommandRunner` bound to it.
Args:
key (str): Primary key used to invoke the command.
description (str): Short description of the command.
action (BaseAction | Callable[..., Any]): Underlying execution logic for
the command.
program (str | None): Program name used in CLI usage text, invocation-path
rendering, and built-in help output. If `None`, an empty program name is
used.
runner_hooks (HookManager | None): Optional executor-level hooks for the
runner.
args (tuple): Static positional arguments applied to the command.
kwargs (dict[str, Any] | None): Static keyword arguments applied to the
command.
hidden (bool): Whether the command should be hidden from menu displays.
aliases (list[str] | None): Optional alternate invocation names.
help_text (str): Help text shown in command help output.
help_epilog (str): Additional help text shown after the main help body.
style (str): Rich style used for rendering the command.
confirm (bool): Whether confirmation is required before execution.
confirm_message (str): Confirmation prompt text.
preview_before_confirm (bool): Whether to preview before confirmation.
spinner (bool): Whether to enable spinner integration.
spinner_message (str): Spinner message text.
spinner_type (str): Spinner animation type.
spinner_style (str): Spinner style.
spinner_speed (float): Spinner speed multiplier.
options (OptionsManager | None): Shared options manager for the command
and runner.
command_hooks (HookManager | None): Optional hook manager for the built
command itself.
before_hooks (list[Callable] | None): Command hooks registered for the
`BEFORE` lifecycle stage.
success_hooks (list[Callable] | None): Command hooks registered for the
`ON_SUCCESS` lifecycle stage.
error_hooks (list[Callable] | None): Command hooks registered for the
`ON_ERROR` lifecycle stage.
after_hooks (list[Callable] | None): Command hooks registered for the
`AFTER` lifecycle stage.
teardown_hooks (list[Callable] | None): Command hooks registered for the
`ON_TEARDOWN` lifecycle stage.
tags (list[str] | None): Optional tags used for grouping and filtering.
logging_hooks (bool): Whether to enable debug hook logging.
retry (bool): Whether retry behavior is enabled.
retry_all (bool): Whether retry behavior should be applied recursively.
retry_policy (RetryPolicy | None): Retry configuration for the command.
arg_parser (CommandArgumentParser | None): Optional explicit argument
parser instance.
arguments (list[dict[str, Any]] | None): Declarative argument
definitions.
argument_config (Callable[[CommandArgumentParser], None] | None):
Callback used to configure the argument parser.
execution_options (list[ExecutionOption | str] | None): Execution-level
options to enable for the command.
custom_parser (ArgParserProtocol | None): Optional custom parser
implementation.
custom_help (Callable[[], str | None] | None): Optional custom help
renderer.
custom_tldr (Callable[[], str | None] | None): Optional custom TLDR
renderer.
custom_usage (Callable[[], str | None] | None): Optional custom usage
renderer.
auto_args (bool): Whether to infer arguments automatically from the
action signature.
arg_metadata (dict[str, str | dict[str, Any]] | None): Optional
metadata used during argument inference.
simple_help_signature (bool): Whether to use a simplified help
signature.
ignore_in_history (bool): Whether to exclude the command from execution
history tracking.
console (Console | None): Optional Rich console for output.
Returns:
CommandRunner: A runner wrapping the newly built command.
Raises:
NotAFalyxError: If `arg_parser` is provided but is not a
`CommandArgumentParser` instance.
InvalidHookError: If `runner_hooks` is provided but is not a `HookManager`
Notes:
- This method is intended as a standalone convenience factory.
- Command construction is delegated to `Command.build()` so command
configuration remains centralized.
"""
options = options or OptionsManager()
command = Command.build(
key=key,
description=description,
action=action,
program=program,
args=args,
kwargs=kwargs,
hidden=hidden,
aliases=aliases,
help_text=help_text,
help_epilog=help_epilog,
style=style,
confirm=confirm,
confirm_message=confirm_message,
preview_before_confirm=preview_before_confirm,
spinner=spinner,
spinner_message=spinner_message,
spinner_type=spinner_type,
spinner_style=spinner_style,
spinner_speed=spinner_speed,
tags=tags,
logging_hooks=logging_hooks,
retry=retry,
retry_all=retry_all,
retry_policy=retry_policy,
options_manager=options,
hooks=command_hooks,
before_hooks=before_hooks,
success_hooks=success_hooks,
error_hooks=error_hooks,
after_hooks=after_hooks,
teardown_hooks=teardown_hooks,
arg_parser=arg_parser,
execution_options=execution_options,
arguments=arguments,
argument_config=argument_config,
custom_parser=custom_parser,
custom_help=custom_help,
custom_tldr=custom_tldr,
custom_usage=custom_usage,
auto_args=auto_args,
arg_metadata=arg_metadata,
simple_help_signature=simple_help_signature,
ignore_in_history=ignore_in_history,
)
if runner_hooks and not isinstance(runner_hooks, HookManager):
raise InvalidHookError("runner_hooks must be an instance of HookManager.")
return cls(
command=command,
options=options,
runner_hooks=runner_hooks,
console=console,
)

257
falyx/completer.py Normal file
View File

@@ -0,0 +1,257 @@
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Prompt Toolkit completion support for routed Falyx command input.
This module defines `FalyxCompleter`, the interactive completion layer used by
Falyx menu and prompt-driven CLI sessions. The completer is routing-aware: it
delegates namespace traversal to `Falyx.resolve_completion_route()` and only
hands control to a command's `CommandArgumentParser` after a leaf command has
been identified.
Completion behavior is split into two phases:
1. Namespace completion
While the user is still selecting a command or namespace entry, completion
candidates are derived from the active namespace via
`completion_names`. Namespace-level help flags such as `-h`, `--help`,
`-T`, and `--tldr` are also suggested when appropriate.
2. Leaf-command completion
Once routing reaches a concrete command, the remaining argv fragment is
delegated to `CommandArgumentParser.suggest_next()` so command-specific
flags, values, choices, and positional suggestions can be surfaced.
The completer also supports preview-prefixed input such as `?deploy`, preserves
shell-safe quoting for suggestions containing whitespace, and integrates
directly with Prompt Toolkit's completion API by yielding `Completion`
instances.
Typical usage:
session = PromptSession(completer=FalyxCompleter(falyx))
"""
from __future__ import annotations
import os
import shlex
from typing import TYPE_CHECKING, Iterable
from prompt_toolkit.completion import Completer, Completion
from prompt_toolkit.document import Document
if TYPE_CHECKING:
from falyx import Falyx
class FalyxCompleter(Completer):
"""Prompt Toolkit completer for routed Falyx input.
`FalyxCompleter` provides context-aware completions for interactive Falyx
sessions. It first asks the owning `Falyx` instance to resolve the current
input into a partial completion route. Based on that route, it either:
- suggests visible entries from the active namespace, or
- delegates argument completion to the resolved command's argument parser.
This keeps completion aligned with Falyx's routing model so nested
namespaces, preview-prefixed commands, and command-local argument parsing
all behave consistently with actual execution.
Args:
falyx (Falyx): Active Falyx application instance used to resolve routes
and retrieve completion candidates.
"""
def __init__(self, falyx: Falyx):
"""Initialize the completer with a bound Falyx instance.
Args:
falyx (Falyx): Active Falyx application that owns the routing and
command metadata used for completion.
"""
self.falyx = falyx
def get_completions(self, document: Document, complete_event):
"""Yield completions for the current input buffer.
This method is the main Prompt Toolkit completion entrypoint. It parses
the text before the cursor, determines whether the user is still routing
through namespaces or has already reached a leaf command, and then
yields matching `Completion` objects.
Behavior:
- Splits the current input using `shlex.split()`.
- Detects preview-mode input prefixed with `?`.
- Separates committed tokens from the active stub under the cursor.
- Resolves the partial route through `Falyx.resolve_completion_route()`.
- Suggests namespace entries and namespace help flags while routing.
- Delegates leaf-command completion to
`CommandArgumentParser.suggest_next()` once a command is resolved.
- Preserves shell-safe quoting for suggestions containing spaces.
Args:
document (Document): Prompt Toolkit document representing the current
input buffer and cursor position.
complete_event: Prompt Toolkit completion event metadata. It is not
currently inspected directly.
Yields:
Completion: Completion candidates appropriate to the current routed
input state.
Notes:
- Invalid shell quoting causes completion to stop silently rather
than raising.
- Command-specific completion is only attempted after a concrete leaf
command has been resolved.
"""
text = document.text_before_cursor
try:
tokens = shlex.split(text)
cursor_at_end = text.endswith((" ", "\t"))
except ValueError:
return
is_preview = False
if tokens and tokens[0].startswith("?"):
is_preview = True
tokens[0] = tokens[0][1:]
if cursor_at_end:
committed_tokens = tokens
stub = ""
else:
committed_tokens = tokens[:-1] if tokens else []
stub = tokens[-1] if tokens else ""
context = self.falyx.get_current_invocation_context().model_copy(
update={"is_preview": is_preview}
)
route = self.falyx.resolve_completion_route(
committed_tokens,
stub=stub,
cursor_at_end_of_token=cursor_at_end,
invocation_context=context,
is_preview=is_preview,
)
# Still selecting an entry in the current namespace
if route.expecting_entry:
suggestions = self._suggest_namespace_entries(route.namespace, route.stub)
# Only here should namespace-level help/TLDR be suggested.
# TODO: better completer in FalyxParser
if not route.command: # and (not route.stub or route.stub.startswith("-")):
for flag in route.namespace.parser._options_by_dest:
if flag.startswith(route.stub):
suggestions.append(flag)
if route.is_preview:
suggestions = [f"?{s}" for s in suggestions]
current_stub = f"?{route.stub}" if route.stub else "?"
else:
current_stub = route.stub
yield from self._yield_lcp_completions(suggestions, current_stub)
return
# Leaf command: CAP owns the rest
if not route.command or not route.command.arg_parser:
return
leaf_tokens = list(route.leaf_argv)
if route.stub:
leaf_tokens.append(route.stub)
try:
suggestions = route.command.arg_parser.suggest_next(
leaf_tokens,
route.cursor_at_end_of_token,
)
except Exception:
return
yield from self._yield_lcp_completions(suggestions, route.stub)
def _suggest_namespace_entries(self, namespace: Falyx, prefix: str) -> list[str]:
"""Return matching visible entry names for a namespace prefix.
This helper filters the current namespace's visible completion names so
only entries beginning with the provided prefix are returned. Case of the
returned value is adjusted to follow the case style of the typed prefix.
Args:
namespace (Falyx): Namespace whose entries should be searched for
completion candidates.
prefix (str): Current partially typed entry name.
Returns:
list[str]: Matching namespace entry keys and aliases.
"""
results: list[str] = []
for name in namespace.completion_names:
if name.upper().startswith(prefix.upper()):
results.append(name.lower() if prefix.islower() else name)
return results
def _ensure_quote(self, text: str) -> str:
"""Quote a completion candidate when it contains whitespace.
Args:
text (str): Raw completion candidate.
Returns:
str: Shell-safe candidate wrapped in double quotes when needed.
"""
if " " in text or "\t" in text:
return f'"{text}"'
return text
def _yield_lcp_completions(self, suggestions, stub) -> Iterable[Completion]:
"""Yield completions for the current stub using longest-common-prefix logic.
Behavior:
- If only one match → yield it fully.
- If multiple matches share a longer prefix → insert the prefix, but also
display all matches in the menu.
- If no shared prefix → list all matches individually.
Args:
suggestions (list[str]): The raw suggestions to consider.
stub (str): The currently typed prefix (used to offset insertion).
Yields:
Completion: Completion objects for the Prompt Toolkit menu.
"""
if not suggestions:
return
matches = list(dict.fromkeys(s for s in suggestions if s.startswith(stub)))
if not matches:
return
lcp = os.path.commonprefix(matches)
if len(matches) == 1:
match = matches[0]
yield Completion(
self._ensure_quote(match),
start_position=-len(stub),
display=match,
)
return
if len(lcp) > len(stub) and not lcp.startswith("-"):
yield Completion(
self._ensure_quote(lcp),
start_position=-len(stub),
display=lcp,
)
for match in matches:
yield Completion(
self._ensure_quote(match),
start_position=-len(stub),
display=match,
)

87
falyx/completer_types.py Normal file
View File

@@ -0,0 +1,87 @@
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Completion route models for routed Falyx autocompletion.
This module defines `CompletionRoute`, a lightweight value object used by the
Falyx completion system to describe the partially resolved state of interactive
input during autocompletion.
`CompletionRoute` sits at the boundary between namespace routing and
command-local argument completion. It captures enough information for the
completer to determine whether it should continue suggesting namespace entries
or delegate to a resolved command's argument parser.
Typical usage:
- A user types part of a namespace path or command key.
- Falyx resolves as much of that path as possible.
- The resulting `CompletionRoute` describes the active namespace, any
resolved leaf command, the remaining argv fragment, and the current
token stub under the cursor.
- `FalyxCompleter` uses this information to decide what completions to
surface next.
This module is intentionally small and focused. It does not perform routing or
completion itself; it only models the routed state needed by the completer.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from falyx.context import InvocationContext
if TYPE_CHECKING:
from falyx.command import Command
from falyx.falyx import Falyx
@dataclass(slots=True)
class CompletionRoute:
"""Represents a partially resolved route used during autocompletion.
A `CompletionRoute` describes the current routed state of user input while
Falyx is generating interactive completions. It distinguishes between two
broad states:
- namespace-routing state, where the user is still selecting a visible entry
within the current namespace
- leaf-command state, where a concrete command has been resolved and the
remaining input should be completed by that command's argument parser
Attributes:
namespace (Falyx): The active namespace in which completion is currently
taking place.
context (InvocationContext): Invocation-path context used to preserve the
routed command path and render context-aware help or usage text.
command (Command | None): The resolved leaf command, if routing has
already reached a concrete command. Remains `None` while the user is
still navigating namespaces.
leaf_argv (list[str]): Remaining command-local argv tokens that belong to
the resolved leaf command. These are typically passed to the
command's argument parser for completion.
stub (str): The current token fragment under the cursor. This is the
partial text that completion candidates should replace or extend.
cursor_at_end_of_token (bool): Whether the cursor is positioned at the
end of a completed token boundary, such as immediately after a
trailing space.
expecting_entry (bool): Whether completion should suggest namespace
entries rather than command-local arguments.
is_preview (bool): Whether the input is in preview mode, such as when
the user begins the invocation with `?`.
Notes:
- This model is completion-only and is intentionally separate from
full execution routing types such as `RouteResult`.
- `CompletionRoute` does not validate or parse command arguments; it
only records the routed state needed to decide what should complete
next.
"""
namespace: Falyx
context: InvocationContext
command: Command | None = None
leaf_argv: list[str] = field(default_factory=list)
stub: str = ""
cursor_at_end_of_token: bool = False
expecting_entry: bool = False
is_preview: bool = False

View File

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

18
falyx/console.py Normal file
View File

@@ -0,0 +1,18 @@
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Global console instance for Falyx CLI applications."""
from rich.console import Console
from falyx.themes import OneColors, get_nord_theme
console = Console(color_system="truecolor", theme=get_nord_theme())
error_console = Console(color_system="truecolor", theme=get_nord_theme(), stderr=True)
def print_error(
message: str | Exception,
*,
hint: str | None = None,
) -> None:
error_console.print(f"[{OneColors.DARK_RED}]error:[/] {message}")
if hint:
error_console.print(f"[{OneColors.LIGHT_YELLOW}]hint:[/] {hint}")

View File

@@ -1,28 +1,38 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
""" """Context models for Falyx execution and invocation state.
Execution context management for Falyx CLI actions.
This module defines `ExecutionContext` and `SharedContext`, which are responsible for This module defines the core context objects used throughout Falyx to track both
capturing per-action and cross-action metadata during CLI workflow execution. These runtime execution metadata and routed invocation-path state.
context objects provide structured introspection, result tracking, error recording,
and time-based performance metrics.
- `ExecutionContext`: Captures runtime information for a single action execution, It provides:
including arguments, results, exceptions, timing, and logging. - `ExecutionContext` for per-action execution details such as arguments,
- `SharedContext`: Maintains shared state and result propagation across results, exceptions, timing, and summary logging.
`ChainedAction` or `ActionGroup` executions. - `SharedContext` for transient shared state across grouped or chained
actions, including propagated results, indexed errors, and arbitrary
shared data.
- `InvocationContext` for capturing the current routed command path as an
immutable value object that supports both plain-text and Rich-markup
rendering.
These contexts enable rich introspection, traceability, and workflow coordination, Together, these models support Falyx lifecycle hooks, execution tracing,
supporting hook lifecycles, retries, and structured output generation. history/introspection, and context-aware help and usage rendering across CLI
and menu modes.
""" """
from __future__ import annotations from __future__ import annotations
import time import time
from datetime import datetime from datetime import datetime
from traceback import format_exception
from typing import Any from typing import Any
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from rich.console import Console from rich.console import Console
from rich.markup import escape
from rich.style import Style
from falyx.console import console
from falyx.display_types import StyledSegment
from falyx.mode import FalyxMode
class ExecutionContext(BaseModel): class ExecutionContext(BaseModel):
@@ -40,7 +50,7 @@ class ExecutionContext(BaseModel):
kwargs (dict): Keyword arguments passed to the action. kwargs (dict): Keyword arguments passed to the action.
action (BaseAction | Callable): The action instance being executed. action (BaseAction | Callable): The action instance being executed.
result (Any | None): The result of the action, if successful. result (Any | None): The result of the action, if successful.
exception (Exception | None): The exception raised, if execution failed. exception (BaseException | None): The exception raised, if execution failed.
start_time (float | None): High-resolution performance start time. start_time (float | None): High-resolution performance start time.
end_time (float | None): High-resolution performance end time. end_time (float | None): High-resolution performance end time.
start_wall (datetime | None): Wall-clock timestamp when execution began. start_wall (datetime | None): Wall-clock timestamp when execution began.
@@ -70,18 +80,21 @@ class ExecutionContext(BaseModel):
name: str name: str
args: tuple = () args: tuple = ()
kwargs: dict = {} kwargs: dict = Field(default_factory=dict)
action: Any action: Any
result: Any | None = None result: Any | None = None
exception: Exception | None = None traceback: str | None = None
_exception: BaseException | None = None
start_time: float | None = None start_time: float | None = None
end_time: float | None = None end_time: float | None = None
start_wall: datetime | None = None start_wall: datetime | None = None
end_wall: datetime | None = None end_wall: datetime | None = None
index: int | None = None
extra: dict[str, Any] = Field(default_factory=dict) extra: dict[str, Any] = Field(default_factory=dict)
console: Console = Field(default_factory=lambda: Console(color_system="auto")) console: Console = console
shared_context: SharedContext | None = None shared_context: SharedContext | None = None
@@ -118,11 +131,33 @@ class ExecutionContext(BaseModel):
def status(self) -> str: def status(self) -> str:
return "OK" if self.success else "ERROR" return "OK" if self.success else "ERROR"
@property
def exception(self) -> BaseException | None:
return self._exception
@exception.setter
def exception(self, exc: BaseException | None):
self._exception = exc
if exc is not None:
self.traceback = "".join(format_exception(exc)).strip()
@property
def signature(self) -> str:
"""
Returns a string representation of the action signature, including
its name and arguments.
"""
args = ", ".join(map(repr, self.args))
kwargs = ", ".join(f"{key}={value!r}" for key, value in self.kwargs.items())
signature = ", ".join(filter(None, [args, kwargs]))
return f"{self.action} ({signature})"
def as_dict(self) -> dict: def as_dict(self) -> dict:
return { return {
"name": self.name, "name": self.name,
"result": self.result, "result": self.result,
"exception": repr(self.exception) if self.exception else None, "exception": repr(self.exception) if self.exception else None,
"traceback": self.traceback,
"duration": self.duration, "duration": self.duration,
"extra": self.extra, "extra": self.extra,
} }
@@ -140,9 +175,9 @@ class ExecutionContext(BaseModel):
message.append(f"Duration: {summary['duration']:.3f}s | ") message.append(f"Duration: {summary['duration']:.3f}s | ")
if summary["exception"]: if summary["exception"]:
message.append(f"Exception: {summary['exception']}") message.append(f"Exception: {summary['exception']}")
else: else:
message.append(f"Result: {summary['result']}") message.append(f"Result: {summary['result']}")
(logger or self.console.print)("".join(message)) (logger or self.console.print)("".join(message))
def to_log_line(self) -> str: def to_log_line(self) -> str:
@@ -192,11 +227,11 @@ class SharedContext(BaseModel):
Attributes: Attributes:
name (str): Identifier for the context (usually the parent action name). name (str): Identifier for the context (usually the parent action name).
results (list[Any]): Captures results from each action, in order of execution. results (list[Any]): Captures results from each action, in order of execution.
errors (list[tuple[int, Exception]]): Indexed list of errors from failed actions. errors (list[tuple[int, BaseException]]): Indexed list of errors from failed actions.
current_index (int): Index of the currently executing action (used in chains). current_index (int): Index of the currently executing action (used in chains).
is_parallel (bool): Whether the context is used in parallel mode (ActionGroup). is_concurrent (bool): Whether the context is used in concurrent mode (ActionGroup).
shared_result (Any | None): Optional shared value available to all actions in shared_result (Any | None): Optional shared value available to all actions in
parallel mode. concurrent mode.
share (dict[str, Any]): Custom shared key-value store for user-defined share (dict[str, Any]): Custom shared key-value store for user-defined
communication communication
between actions (e.g., flags, intermediate data, settings). between actions (e.g., flags, intermediate data, settings).
@@ -217,9 +252,9 @@ class SharedContext(BaseModel):
name: str name: str
action: Any action: Any
results: list[Any] = Field(default_factory=list) results: list[Any] = Field(default_factory=list)
errors: list[tuple[int, Exception]] = Field(default_factory=list) errors: list[tuple[int, BaseException]] = Field(default_factory=list)
current_index: int = -1 current_index: int = -1
is_parallel: bool = False is_concurrent: bool = False
shared_result: Any | None = None shared_result: Any | None = None
share: dict[str, Any] = Field(default_factory=dict) share: dict[str, Any] = Field(default_factory=dict)
@@ -229,16 +264,16 @@ class SharedContext(BaseModel):
def add_result(self, result: Any) -> None: def add_result(self, result: Any) -> None:
self.results.append(result) self.results.append(result)
def add_error(self, index: int, error: Exception) -> None: def add_error(self, index: int, error: BaseException) -> None:
self.errors.append((index, error)) self.errors.append((index, error))
def set_shared_result(self, result: Any) -> None: def set_shared_result(self, result: Any) -> None:
self.shared_result = result self.shared_result = result
if self.is_parallel: if self.is_concurrent:
self.results.append(result) self.results.append(result)
def last_result(self) -> Any: def last_result(self) -> Any:
if self.is_parallel: if self.is_concurrent:
return self.shared_result return self.shared_result
return self.results[-1] if self.results else None return self.results[-1] if self.results else None
@@ -249,14 +284,155 @@ class SharedContext(BaseModel):
self.share[key] = value self.share[key] = value
def __str__(self) -> str: def __str__(self) -> str:
parallel_label = "Parallel" if self.is_parallel else "Sequential" concurrent_label = "Concurrent" if self.is_concurrent else "Sequential"
return ( return (
f"<{parallel_label}SharedContext '{self.name}' | " f"<{concurrent_label}SharedContext '{self.name}' | "
f"Results: {self.results} | " f"Results: {self.results} | "
f"Errors: {self.errors}>" f"Errors: {self.errors}>"
) )
class InvocationContext(BaseModel):
"""Immutable invocation-path context for routed Falyx help and execution.
`InvocationContext` captures the current displayable command path as the router
descends through namespaces and commands. It stores both the raw typed path
(`typed_path`) and a styled segment representation (`segments`) so the same
context can be rendered as plain text or Rich markup.
This model is intended to be treated as an immutable value object. Methods such
as `with_path_segment()` and `without_last_path_segment()` return new context
instances rather than mutating the existing one.
Attributes:
program (str): Root program name used in CLI-mode help and usage output.
program_style (Style | str): Rich style applied to the program name when rendering
`markup_path`.
typed_path (list[str]): Raw invocation tokens collected during routing,
excluding the root program name.
segments (list[StyledSegment]): Styled path segments used to render the
invocation path with Rich markup.
mode (FalyxMode): Active Falyx mode for this invocation context. This is
used to determine whether the path should include the program name.
is_preview (bool): Whether the current invocation is a preview flow rather
than a normal execution flow.
"""
program: str = ""
program_style: Style | str = ""
typed_path: list[str] = Field(default_factory=list)
segments: list[StyledSegment] = Field(default_factory=list)
mode: FalyxMode = FalyxMode.MENU
is_preview: bool = False
model_config = ConfigDict(arbitrary_types_allowed=True)
@property
def is_cli_mode(self) -> bool:
"""Whether this context should render using CLI path semantics.
Returns:
bool: `True` when the invocation is not in menu mode, meaning rendered
paths should include the program name. `False` when in menu mode.
"""
return self.mode != FalyxMode.MENU
def with_path_segment(
self,
token: str,
*,
style: Style | str | None = None,
) -> InvocationContext:
"""Return a new context with one additional path segment appended.
This method preserves the current context and creates a new
`InvocationContext` with the provided token added to both `typed_path` and
`segments`.
Args:
token (str): Raw path token to append, such as a namespace key,
command key, or alias.
style (str | None): Optional Rich style for the appended segment.
Returns:
InvocationContext: A new context containing the appended path segment.
"""
return InvocationContext(
program=self.program,
program_style=self.program_style,
typed_path=[*self.typed_path, token],
segments=[*self.segments, StyledSegment(text=token, style=style)],
mode=self.mode,
is_preview=self.is_preview,
)
def without_last_path_segment(self) -> InvocationContext:
"""Return a new context with the last path segment removed.
This method preserves the current context and creates a new
`InvocationContext` with the last token removed from both `typed_path` and
`segments`.
Returns:
InvocationContext: A new context with the last path segment removed, or the
current context if no path segments are present.
"""
if not self.typed_path:
return self
return InvocationContext(
program=self.program,
program_style=self.program_style,
typed_path=self.typed_path[:-1],
segments=self.segments[:-1],
mode=self.mode,
is_preview=self.is_preview,
)
@property
def plain_path(self) -> str:
"""Render the invocation path as plain text.
In CLI mode, the rendered path includes the root program name followed by
all collected path segments. In menu mode, only the collected path segments
are rendered.
Returns:
str: Plain-text invocation path suitable for logs, comparisons, or
non-styled help output.
"""
parts = [seg.text for seg in self.segments]
if self.is_cli_mode:
return " ".join([self.program, *parts]).strip()
return " ".join(parts).strip()
@property
def markup_path(self) -> str:
"""Render the invocation path as escaped Rich markup.
In CLI mode, the root program name is included and styled with
`program_style` when provided. Each path segment is escaped and styled
using its associated `StyledSegment.style` value when present.
Returns:
str: Rich-markup invocation path suitable for help and usage rendering.
"""
parts: list[str] = []
if self.is_cli_mode and self.program:
if self.program_style:
parts.append(
f"[{self.program_style}]{escape(self.program)}[/{self.program_style}]"
)
else:
parts.append(escape(self.program))
for seg in self.segments:
if seg.style:
parts.append(f"[{seg.style}]{escape(seg.text)}[/{seg.style}]")
else:
parts.append(escape(seg.text))
return " ".join(parts).strip()
if __name__ == "__main__": if __name__ == "__main__":
import asyncio import asyncio

View File

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

33
falyx/display_types.py Normal file
View File

@@ -0,0 +1,33 @@
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Display types for Falyx.
This module defines data models used for representing styled display elements in
Falyx's CLI output, such as command paths, namespaces, and TLDR examples. These
models are designed to be simple containers for the raw text and styling
information needed to render consistent and visually appealing CLI interfaces using
the Rich library.
It provides:
- `StyledSegment` for representing a single styled token.
"""
from pydantic import BaseModel, ConfigDict
from rich.style import Style
class StyledSegment(BaseModel):
"""Styled path segment used to build Rich styled markup.
`StyledSegment` represents a single token. It stores the raw display
text and an optional Rich style so text can be rendered either
as plain text or styled markup.
Attributes:
text (str): Display text for this path segment.
style (str | None): Optional Rich style applied when rendering this
segment in markup output.
"""
text: str
style: Style | str | None = None
model_config = ConfigDict(arbitrary_types_allowed=True)

View File

@@ -1,13 +1,45 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""exceptions.py""" """Defines all custom exception classes used in the Falyx CLI framework.
These exceptions provide structured error handling for common failure cases,
including command conflicts, invalid actions or hooks, parser errors, and execution guards
like circuit breakers or empty workflows.
All exceptions inherit from `FalyxError`, the base exception for the framework.
Exception Hierarchy:
- FalyxError
├── CommandAlreadyExistsError
├── InvalidHookError
├── InvalidActionError
├── NotAFalyxError
├── CircuitBreakerOpen
├── EmptyChainError
├── EmptyGroupError
├── EmptyPoolError
├── CommandArgumentError
└── EntryNotFoundError
These are raised internally throughout the Falyx system to signal user-facing or
developer-facing problems that should be caught and reported.
"""
class FalyxError(Exception): class FalyxError(Exception):
"""Custom exception for the Menu class.""" """Base exception class for all Falyx CLI framework errors."""
def __init__(
self,
message: str | None = None,
hint: str | None = None,
):
if message:
super().__init__(message)
self.hint = hint
class CommandAlreadyExistsError(FalyxError): class CommandAlreadyExistsError(FalyxError):
"""Exception raised when an command with the same key already exists in the menu.""" """Exception raised when an command with the same key already exists in the Falyx instance."""
class InvalidHookError(FalyxError): class InvalidHookError(FalyxError):
@@ -19,7 +51,7 @@ class InvalidActionError(FalyxError):
class NotAFalyxError(FalyxError): class NotAFalyxError(FalyxError):
"""Exception raised when the provided submenu is not an instance of Menu.""" """Exception raised when the provided object is not an instance of a Falyx class."""
class CircuitBreakerOpen(FalyxError): class CircuitBreakerOpen(FalyxError):
@@ -30,5 +62,160 @@ class EmptyChainError(FalyxError):
"""Exception raised when the chain is empty.""" """Exception raised when the chain is empty."""
class CommandArgumentError(FalyxError): class EmptyGroupError(FalyxError):
"""Exception raised when the group is empty."""
class EmptyPoolError(FalyxError):
"""Exception raised when the pool is empty."""
class UsageError(FalyxError):
"""Exception raised when there is an error in the command usage."""
def __init__(
self,
message: str | None = None,
hint: str | None = None,
show_short_usage: bool = True,
):
super().__init__(message, hint)
self.show_short_usage = show_short_usage
class FalyxOptionError(UsageError):
"""Exception raised when there is an error in the Falyx option parser."""
class CommandArgumentError(UsageError):
"""Exception raised when there is an error in the command argument parser.""" """Exception raised when there is an error in the command argument parser."""
class ArgumentGroupError(CommandArgumentError):
"""Exception raised when there is an error in the argument group."""
class ArgumentParsingError(CommandArgumentError):
"""Exception raised when there is an error during argument parsing."""
def __init__(
self,
message: str | None = None,
hint: str | None = None,
show_short_usage: bool = True,
command_key: str | None = None,
dest: str | None = None,
token: str | None = None,
):
self.command_key = command_key
self.dest = dest
self.token = token
super().__init__(message, hint, show_short_usage)
class EntryNotFoundError(UsageError):
"""Exception raised when a routing entry is not found."""
def __init__(
self,
unknown_name: str,
suggestions: list[str] | None = None,
message_context: str = "",
show_short_usage: bool = True,
):
self.unknown_name = unknown_name
self.suggestions = suggestions
self.message_context = message_context
super().__init__(
self.build_message(),
self.build_hint(),
show_short_usage,
)
def build_message(self) -> str:
prefix = f"{self.message_context}: " if self.message_context else ""
return f"{prefix}unknown command or namespace '{self.unknown_name}'."
def build_hint(self) -> str | None:
if self.suggestions:
return f"did you mean: {', '.join(self.suggestions[:10])}?"
else:
return None
class UnrecognizedOptionError(ArgumentParsingError):
def __init__(
self,
token: str,
remaining_flags: list[str] | None = None,
show_short_usage: bool = True,
):
self.remaining_flags = remaining_flags
self.token = token
super().__init__(
self.build_message(),
self.build_hint(),
show_short_usage=show_short_usage,
token=token,
)
def build_message(self) -> str:
return f"unrecognized option '{self.token}'"
def build_hint(self) -> str:
if self.remaining_flags:
return f"did you mean one of: {', '.join(self.remaining_flags)}?"
return "use --help to see available options"
class InvalidValueError(ArgumentParsingError):
def __init__(
self,
dest: str | None = None,
choices: list[str] | None = None,
expected: str | None = None,
error: Exception | str | None = None,
show_short_usage: bool = True,
):
self.choices = choices
self.expected = expected
self.error = error
self.dest = dest
super().__init__(
self.build_message(),
self.build_hint(),
show_short_usage=show_short_usage,
dest=dest,
)
def build_message(self) -> str:
if self.dest and self.choices:
return f"invalid value for '{self.dest}'"
elif self.dest and self.error:
return f"invalid value for '{self.dest}': {self.error}"
elif self.dest and self.expected:
return f"invalid value for '{self.dest}': expected {self.expected}"
else:
return "invalid command argument value."
def build_hint(self) -> str | None:
if self.dest and self.choices:
return f"the value for '{self.dest}' must be one of {{{', '.join(self.choices)}}}."
else:
return None
class MissingValueError(ArgumentParsingError):
def __init__(
self,
dest: str,
expected_count: int | None = None,
actual_count: int | None = None,
):
self.expected_count = expected_count
self.actual_count = actual_count
self.dest = dest
class TokenizationError(UsageError):
raw_input: str | None = None

61
falyx/execution_option.py Normal file
View File

@@ -0,0 +1,61 @@
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Execution option enums for the Falyx command runtime.
This module defines `ExecutionOption`, the enum used to represent optional
execution-scoped behaviors that a command may choose to expose through its
argument parser.
Execution options are distinct from normal command inputs. They control runtime
behavior around command execution rather than the business-logic arguments
passed to the underlying action. Typical examples include summary output,
retry configuration, and confirmation handling.
`ExecutionOption` is used by Falyx components such as `Command` and
`CommandArgumentParser` to declaratively enable execution-level flags and to
normalize user- or config-provided option names into a validated enum value.
The enum also implements custom missing-value handling so string inputs can be
resolved case-insensitively with helpful error messages.
"""
from __future__ import annotations
from enum import Enum
class ExecutionOption(Enum):
"""Enumerates optional execution-scoped behaviors supported by Falyx.
`ExecutionOption` identifies runtime features that can be enabled on a
command independently of its normal argument schema. When present, these
options typically cause `CommandArgumentParser` to expose additional flags
that affect how the command is executed rather than what the command does.
Supported options:
SUMMARY: Enable summary-related execution flags and reporting behavior.
RETRY: Enable retry-related execution flags such as retry count, delay,
and backoff.
CONFIRM: Enable confirmation-related execution flags such as forcing or
skipping confirmation prompts.
Notes:
- These values are intended for execution control, not domain-specific
command input.
- String values are normalized case-insensitively through `_missing_()`
so config and user input can be converted into enum members with
friendlier validation behavior.
"""
SUMMARY = "summary"
RETRY = "retry"
CONFIRM = "confirm"
@classmethod
def _missing_(cls, value: object) -> ExecutionOption:
if not isinstance(value, str):
raise ValueError(f"Invalid {cls.__name__}: {value!r}")
normalized = value.strip().lower()
for member in cls:
if member.value == normalized:
return member
valid = ", ".join(member.value for member in cls)
raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}")

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

42
falyx/mode.py Normal file
View File

@@ -0,0 +1,42 @@
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Runtime mode definitions for the Falyx CLI framework.
This module defines `FalyxMode`, the enum used to represent the high-level
operating mode of a Falyx application during parsing, routing, rendering, and
execution.
These modes describe the current intent of the runtime rather than any
particular command. They are used throughout Falyx to coordinate behavior such
as whether the application should show an interactive menu, execute a routed
command, render help output, preview a command, or surface an error state.
`FalyxMode` is commonly stored in shared runtime state and passed through
invocation and parsing layers so UI rendering and execution flow remain
consistent across CLI and menu-driven entrypoints.
"""
from enum import Enum
class FalyxMode(Enum):
"""Enumerates the high-level runtime modes used by Falyx.
`FalyxMode` provides a small set of application-wide states that describe
how the current invocation should be handled.
Attributes:
MENU: Interactive menu mode using Prompt Toolkit input and menu
rendering.
COMMAND: Direct command-execution mode for routed CLI or programmatic
invocation.
PREVIEW: Non-executing preview mode used to inspect a command before it
runs.
HELP: Help-rendering mode for namespace, command, or TLDR output.
ERROR: Error state used when invocation handling should surface a
failure condition.
"""
MENU = "menu"
COMMAND = "command"
PREVIEW = "preview"
HELP = "help"
ERROR = "error"

68
falyx/namespace.py Normal file
View File

@@ -0,0 +1,68 @@
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Namespace entry model for nested Falyx applications.
This module defines `FalyxNamespace`, the lightweight metadata container used to
register one `Falyx` instance inside another as a routed namespace entry.
A `FalyxNamespace` describes how a nested application should appear and behave
from the perspective of its parent namespace. It stores the public-facing key,
description, aliases, styling, and visibility flags used for routing,
completion, help rendering, and menu display, while holding a reference to the
child `Falyx` runtime that should take over once the namespace is entered.
This model is intentionally small and declarative. It does not implement
routing, rendering, or execution itself; those responsibilities remain with the
parent and child `Falyx` instances.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from rich.style import StyleType
from falyx.context import InvocationContext
from falyx.themes import OneColors
if TYPE_CHECKING:
from falyx.falyx import Falyx
@dataclass
class FalyxNamespace:
"""Represents a nested `Falyx` application exposed as a namespace entry.
`FalyxNamespace` is used by a parent `Falyx` instance to register and
describe a child `Falyx` runtime as a routable namespace. It provides the
metadata needed to expose that child namespace consistently across command
resolution, completion, help output, and menu rendering.
Attributes:
key (str): Primary identifier used to enter the namespace.
description (str): User-facing namespace description.
namespace (Falyx): Nested `Falyx` instance activated when this namespace is
selected.
aliases (list[str]): Optional alternate names that may also resolve to the same
namespace.
help_text (str): Optional short help text used in listings or help output.
style (StyleType): Rich style used when rendering the namespace key or aliases.
hidden (bool): Whether the namespace should be omitted from visible menus and
help listings.
"""
key: str
description: str
namespace: Falyx
aliases: list[str] = field(default_factory=list)
help_text: str = ""
style: StyleType = OneColors.CYAN
hidden: bool = False
def get_help_signature(
self, invocation_context: InvocationContext
) -> tuple[str, str, str | None]:
"""Returns the usage signature for this namespace, used in help rendering."""
usage = f"{self.key} {self.namespace._get_usage_fragment(invocation_context)}"
if self.aliases:
usage += f" (aliases: {', '.join(self.aliases)})"
return usage, self.description, self.help_text

View File

@@ -1,43 +1,173 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""options_manager.py""" """Option state management for Falyx CLI runtimes.
from argparse import Namespace This module defines `OptionsManager`, a small utility responsible for
storing, retrieving, and temporarily overriding runtime option values across
named namespaces.
Falyx uses this manager to hold global session- and execution-scoped flags such
as verbosity, prompt suppression, confirmation behavior, and other mutable
runtime settings. Options are stored in isolated namespace dictionaries so
different layers of the runtime can share one manager without clobbering each
other's state.
In addition to basic get/set operations, the manager provides helpers for:
- toggling boolean flags
- exposing option access as zero-argument callables for UI bindings
- temporarily overriding a namespace within a context manager
- holding a shared `SpinnerManager` for spinner lifecycle integration
Typical usage:
```
options = OptionsManager()
options.from_mapping({"verbose": True})
if options.get("verbose"):
...
with options.override_namespace({"skip_confirm": True}, "execution"):
...
```
Attributes:
options (defaultdict[str, dict[str, Any]]): Mapping of namespace names to
option dictionaries.
spinners (SpinnerManager): Shared spinner manager available to runtime
components that need coordinated spinner rendering.
"""
from collections import defaultdict from collections import defaultdict
from typing import Any, Callable from contextlib import contextmanager
from typing import Any, Callable, Iterator, Mapping
from falyx.logger import logger from falyx.logger import logger
from falyx.spinner_manager import SpinnerManager
class OptionsManager: class OptionsManager:
"""OptionsManager""" """Manage mutable option values across named runtime namespaces.
def __init__(self, namespaces: list[tuple[str, Namespace]] | None = None) -> None: `OptionsManager` is the central store for Falyx runtime flags. Each option
self.options: defaultdict = defaultdict(Namespace) is stored under a namespace name such as `"default"` or `"execution"`,
allowing global settings and temporary execution-scoped overrides to
coexist in one shared object.
The manager supports direct reads and writes, boolean toggling, namespace
snapshots, and temporary override contexts. It also exposes small callable
wrappers that are useful when integrating option reads or toggles into UI
components such as bottom-bar controls or key bindings.
Args:
namespaces (list[tuple[str, dict[str, Any]]] | None): Optional initial
namespace/value pairs to preload into the manager.
Attributes:
options (defaultdict[str, dict[str, Any]]): Internal namespace-to-option
mapping.
spinners (SpinnerManager): Shared spinner manager used by other Falyx
runtime components.
"""
def __init__(
self,
namespaces: list[tuple[str, dict[str, Any]]] | None = None,
) -> None:
"""Initialize the option manager.
Args:
namespaces (list[tuple[str, dict[str, Any]]] | None): Optional list
of `(namespace_name, values)` pairs to load during
initialization.
"""
self.options: defaultdict = defaultdict(dict)
self.spinners = SpinnerManager()
if namespaces: if namespaces:
for namespace_name, namespace in namespaces: for namespace_name, namespace in namespaces:
self.from_namespace(namespace, namespace_name) self.from_mapping(namespace, namespace_name)
def from_namespace( def from_mapping(
self, namespace: Namespace, namespace_name: str = "cli_args" self,
values: Mapping[str, Any],
namespace_name: str = "default",
) -> None: ) -> None:
self.options[namespace_name] = namespace """Merge option values into a namespace.
Existing keys in the target namespace are updated in place. Missing
namespaces are created automatically.
Args:
values (Mapping[str, Any]): Mapping of option names to values.
namespace_name (str): Target namespace to update. Defaults to
`"default"`.
"""
self.options[namespace_name].update(dict(values))
def get( def get(
self, option_name: str, default: Any = None, namespace_name: str = "cli_args" self,
option_name: str,
default: Any = None,
namespace_name: str = "default",
) -> Any: ) -> Any:
"""Get the value of an option.""" """Return an option value from a namespace.
return getattr(self.options[namespace_name], option_name, default)
def set(self, option_name: str, value: Any, namespace_name: str = "cli_args") -> None: Args:
"""Set the value of an option.""" option_name (str): Name of the option to retrieve.
setattr(self.options[namespace_name], option_name, value) default (Any): Value to return when the option is not present.
Defaults to `None`.
namespace_name (str): Namespace to read from. Defaults to
`"default"`.
def has_option(self, option_name: str, namespace_name: str = "cli_args") -> bool: Returns:
"""Check if an option exists in the namespace.""" Any: The stored option value if present, otherwise `default`.
return hasattr(self.options[namespace_name], option_name) """
return self.options[namespace_name].get(option_name, default)
def toggle(self, option_name: str, namespace_name: str = "cli_args") -> None: def set(
"""Toggle a boolean option.""" self,
option_name: str,
value: Any,
namespace_name: str = "default",
) -> None:
"""Store an option value in a namespace.
Args:
option_name (str): Name of the option to set.
value (Any): Value to store.
namespace_name (str): Namespace to update. Defaults to `"default"`.
"""
self.options[namespace_name][option_name] = value
def has_option(
self,
option_name: str,
namespace_name: str = "default",
) -> bool:
"""Return whether an option exists in a namespace.
Args:
option_name (str): Name of the option to check.
namespace_name (str): Namespace to inspect. Defaults to `"default"`.
Returns:
bool: `True` if the option exists in the namespace, otherwise
`False`.
"""
return option_name in self.options[namespace_name]
def toggle(
self,
option_name: str,
namespace_name: str = "default",
) -> None:
"""Invert a boolean option in place.
Args:
option_name (str): Name of the option to toggle.
namespace_name (str): Namespace containing the option. Defaults to
`"default"`.
Raises:
TypeError: If the target option is missing or is not a boolean.
"""
current = self.get(option_name, namespace_name=namespace_name) current = self.get(option_name, namespace_name=namespace_name)
if not isinstance(current, bool): if not isinstance(current, bool):
raise TypeError( raise TypeError(
@@ -49,9 +179,24 @@ class OptionsManager:
) )
def get_value_getter( def get_value_getter(
self, option_name: str, namespace_name: str = "cli_args" self,
option_name: str,
namespace_name: str = "default",
) -> Callable[[], Any]: ) -> Callable[[], Any]:
"""Get the value of an option as a getter function.""" """Return a zero-argument callable that reads an option value.
This is useful for UI integrations that expect a callback instead of an
eagerly evaluated value.
Args:
option_name (str): Name of the option to read.
namespace_name (str): Namespace to read from. Defaults to
`"default"`.
Returns:
Callable[[], Any]: Function that returns the current option value
when called.
"""
def _getter() -> Any: def _getter() -> Any:
return self.get(option_name, namespace_name=namespace_name) return self.get(option_name, namespace_name=namespace_name)
@@ -59,17 +204,72 @@ class OptionsManager:
return _getter return _getter
def get_toggle_function( def get_toggle_function(
self, option_name: str, namespace_name: str = "cli_args" self,
option_name: str,
namespace_name: str = "default",
) -> Callable[[], None]: ) -> Callable[[], None]:
"""Get the toggle function for a boolean option.""" """Return a zero-argument callable that toggles a boolean option.
This is useful for key bindings, bottom-bar toggles, or other UI hooks
that need a callable action.
Args:
option_name (str): Name of the boolean option to toggle.
namespace_name (str): Namespace containing the option. Defaults to
`"default"`.
Returns:
Callable[[], None]: Function that toggles the option when called.
"""
def _toggle() -> None: def _toggle() -> None:
self.toggle(option_name, namespace_name=namespace_name) self.toggle(option_name, namespace_name=namespace_name)
return _toggle return _toggle
def get_namespace_dict(self, namespace_name: str) -> Namespace: def get_namespace_dict(self, namespace_name: str) -> dict[str, Any]:
"""Return all options in a namespace as a dictionary.""" """Return a shallow copy of one namespace's option dictionary.
Args:
namespace_name (str): Namespace to snapshot.
Returns:
dict[str, Any]: Copy of the namespace's stored options.
Raises:
ValueError: If the requested namespace does not exist.
"""
if namespace_name not in self.options: if namespace_name not in self.options:
raise ValueError(f"Namespace '{namespace_name}' not found.") raise ValueError(f"Namespace '{namespace_name}' not found.")
return vars(self.options[namespace_name]) return dict(self.options[namespace_name])
@contextmanager
def override_namespace(
self,
overrides: Mapping[str, Any],
namespace_name: str = "execution",
) -> Iterator[None]:
"""Temporarily apply option overrides within a namespace.
The current namespace contents are copied before the overrides are
applied. When the context exits, the original namespace state is
restored, even if an exception is raised inside the context block.
Args:
overrides (Mapping[str, Any]): Temporary option values to merge into
the namespace.
namespace_name (str): Namespace to override. Defaults to
`"execution"`.
Yields:
None: Control is yielded to the wrapped context block.
Raises:
ValueError: If the namespace does not already exist.
"""
original = self.get_namespace_dict(namespace_name)
try:
self.from_mapping(values=overrides, namespace_name=namespace_name)
yield
finally:
self.options[namespace_name] = original

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

@@ -0,0 +1,19 @@
"""Falyx CLI Framework
Copyright (c) 2026 rtj.dev LLC.
Licensed under the MIT License. See LICENSE file for details.
"""
from .argument import Argument
from .argument_action import ArgumentAction
from .command_argument_parser import CommandArgumentParser
from .falyx_parser import FalyxParser
from .parse_result import ParseResult
__all__ = [
"Argument",
"ArgumentAction",
"CommandArgumentParser",
"FalyxParser",
"ParseResult",
]

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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,650 @@
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING, Any
from falyx.console import console
from falyx.exceptions import EntryNotFoundError, FalyxOptionError
from falyx.mode import FalyxMode
from falyx.options_manager import OptionsManager
from falyx.parser.parse_result import ParseResult
from falyx.parser.parser_types import (
FalyxTLDRExample,
FalyxTLDRInput,
false_none,
true_none,
)
from falyx.parser.utils import coerce_value, get_type_name
if TYPE_CHECKING:
from falyx.falyx import Falyx
builtin_type = type
class OptionAction(Enum):
STORE = "store"
STORE_TRUE = "store_true"
STORE_FALSE = "store_false"
STORE_BOOL_OPTIONAL = "store_bool_optional"
COUNT = "count"
HELP = "help"
TLDR = "tldr"
@classmethod
def choices(cls) -> list[OptionAction]:
"""Return a list of all argument actions."""
return list(cls)
@classmethod
def _get_alias(cls, value: str) -> str:
aliases = {
"optional": "store_bool_optional",
"true": "store_true",
"false": "store_false",
}
return aliases.get(value, value)
@classmethod
def _missing_(cls, value: object) -> OptionAction:
if not isinstance(value, str):
raise ValueError(f"Invalid {cls.__name__}: {value!r}")
normalized = value.strip().lower()
alias = cls._get_alias(normalized)
for member in cls:
if member.value == alias:
return member
valid = ", ".join(member.value for member in cls)
raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}")
def __str__(self) -> str:
"""Return the string representation of the argument action."""
return self.value
class OptionScope(Enum):
ROOT = "root"
NAMESPACE = "namespace"
@classmethod
def _missing_(cls, value: object) -> OptionScope:
if not isinstance(value, str):
raise ValueError(f"Invalid {cls.__name__}: {value!r}")
normalized = value.strip().lower()
for member in cls:
if member.value == normalized:
return member
valid = ", ".join(member.value for member in cls)
raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}")
@dataclass(slots=True)
class Option:
flags: tuple[str, ...]
dest: str
action: OptionAction = OptionAction.STORE
type: Any = str
default: Any = None
choices: list[str] | None = None
help: str = ""
suggestions: list[str] | None = None
scope: OptionScope = OptionScope.NAMESPACE
def format_for_help(self) -> str:
"""Return a formatted string of the option's flags for help output."""
return ", ".join(self.flags)
class FalyxParser:
RESERVED_DESTS: set[str] = {"help", "tldr"}
def __init__(self, flx: Falyx) -> None:
self._flx = flx
self._options_by_dest: dict[str, Option] = {}
self._options: list[Option] = []
self._dest_set: set[str] = set()
self._tldr_examples: list[FalyxTLDRExample] = []
self._add_reserved_options()
self.help_option: Option | None = None
self.tldr_option: Option | None = None
def get_flags(self) -> list[str]:
"""Return a list of the first flag for the registered options."""
return [option.flags[0] for option in self._options]
def get_options(self) -> list[Option]:
"""Return a list of registered options."""
return self._options
def _add_tldr(self):
"""Add TLDR argument to the parser."""
if "tldr" in self._dest_set:
return None
tldr = Option(
flags=("--tldr", "-T"),
action=OptionAction.TLDR,
help="Show quick usage examples.",
dest="tldr",
default=False,
)
self._register_option(tldr)
self.tldr_option = tldr
def add_tldr_example(
self,
*,
entry_key: str,
usage: str,
description: str,
) -> None:
"""Register a single namespace-level TLDR example.
The referenced entry must resolve to a known command or namespace in the
current `Falyx` instance. Unknown entries are reported to the console and
are not added.
Args:
entry_key (str): Command or namespace key the example is associated with.
usage (str): Example usage fragment shown after the resolved invocation path.
description (str): Short explanation displayed alongside the example.
Raises:
EntryNotFoundError: If `entry_key` cannot be resolved to a known command or
namespace in this `Falyx` instance.
"""
entry, suggestions = self._flx.resolve_entry(entry_key)
if not entry:
raise EntryNotFoundError(
unknown_name=entry_key,
suggestions=suggestions,
message_context="TLDR example",
)
self._tldr_examples.append(
FalyxTLDRExample(entry_key=entry_key, usage=usage, description=description)
)
self._add_tldr()
def add_tldr_examples(self, examples: list[FalyxTLDRInput]) -> None:
"""Register multiple namespace-level TLDR examples.
Supports either `FalyxTLDRExample` objects or shorthand tuples of
`(entry_key, usage, description)`.
Args:
examples (list[FalyxTLDRInput]): Example definitions to validate and append.
Raises:
FalyxError: If an example has an unsupported shape.
EntryNotFoundError: If `entry_key` cannot be resolved to a known command or
namespace in this `Falyx` instance.
"""
for example in examples:
if isinstance(example, FalyxTLDRExample):
entry, suggestions = self._flx.resolve_entry(example.entry_key)
if not entry:
raise EntryNotFoundError(
unknown_name=example.entry_key,
suggestions=suggestions,
message_context="TLDR example",
)
self._tldr_examples.append(example)
self._add_tldr()
elif len(example) == 3:
entry_key, usage, description = example
self.add_tldr_example(
entry_key=entry_key,
usage=usage,
description=description,
)
self._add_tldr()
else:
raise FalyxOptionError(
f"invalid TLDR example format: {example}.\n"
"examples must be either FalyxTLDRExample instances "
"or tuples of (entry_key, usage, description).",
)
def _add_reserved_options(self) -> None:
help = Option(
flags=("-h", "--help", "?"),
dest="help",
action=OptionAction.HELP,
help="Show root-level help output and exit.",
default=False,
)
self._register_option(help)
self.help_option = help
if not self._flx.disable_verbose_option:
verbose = Option(
flags=("-v", "--verbose"),
dest="verbose",
action=OptionAction.STORE_TRUE,
help="Enable verbose logging for the session.",
default=False,
scope=OptionScope.ROOT,
)
self._register_option(verbose)
if not self._flx.disable_debug_hooks_option:
debug_hooks = Option(
flags=("-d", "--debug-hooks"),
dest="debug_hooks",
action=OptionAction.STORE_TRUE,
help="Log hook execution in detail for the session.",
default=False,
scope=OptionScope.ROOT,
)
self._register_option(debug_hooks)
if not self._flx.disable_never_prompt_option:
never_prompt = Option(
flags=("-n", "--never-prompt"),
dest="never_prompt",
action=OptionAction.STORE_TRUE,
help="Suppress all prompts for the session.",
default=False,
scope=OptionScope.ROOT,
)
self._register_option(never_prompt)
def _register_store_bool_optional(
self,
flags: tuple[str, ...],
dest: str,
help: str,
) -> None:
"""Register a store_bool_optional action with the parser."""
if len(flags) != 1:
raise FalyxOptionError(
"store_bool_optional action can only have a single flag"
)
if not flags[0].startswith("--"):
raise FalyxOptionError(
"store_bool_optional action must use a long flag (e.g. --flag)"
)
base_flag = flags[0]
negated_flag = f"--no-{base_flag.lstrip('-')}"
argument = Option(
flags=flags,
dest=dest,
action=OptionAction.STORE_BOOL_OPTIONAL,
type=true_none,
default=None,
help=help,
)
negated_argument = Option(
flags=(negated_flag,),
dest=dest,
action=OptionAction.STORE_BOOL_OPTIONAL,
type=false_none,
default=None,
help=help,
)
self._register_option(argument)
self._register_option(negated_argument, bypass_validation=True)
def _register_option(self, option: Option, bypass_validation: bool = False) -> None:
self._dest_set.add(option.dest)
self._options.append(option)
for flag in option.flags:
if flag in self._options and not bypass_validation:
existing = self._options_by_dest[flag]
raise FalyxOptionError(
f"flag '{flag}' is already used by argument '{existing.dest}'"
)
self._options_by_dest[flag] = option
def _validate_flags(self, flags: tuple[str, ...]) -> None:
if not flags:
raise FalyxOptionError("no flags provided for option")
for flag in flags:
if not isinstance(flag, str):
raise FalyxOptionError(f"invalid flag '{flag}': must be a string")
if not flag.startswith("-"):
raise FalyxOptionError(f"invalid flag '{flag}': must start with '-'")
if flag.startswith("--") and len(flag) < 3:
raise FalyxOptionError(
f"invalid flag '{flag}': long flags must have at least one character after '--'"
)
if flag.startswith("-") and not flag.startswith("--") and len(flag) > 2:
raise FalyxOptionError(
f"invalid flag '{flag}': short flags must be a single character"
)
if flag in self._options_by_dest:
existing = self._options_by_dest[flag]
raise FalyxOptionError(
f"flag '{flag}' is already used by argument '{existing.dest}'"
)
def _get_dest_from_flags(self, flags: tuple[str, ...], dest: str | None) -> str:
if dest:
if not dest.replace("_", "").isalnum():
raise FalyxOptionError(
f"invalid dest '{dest}': must be a valid identifier (letters, digits, and underscores only)"
)
if dest[0].isdigit():
raise FalyxOptionError(
f"invalid dest '{dest}': cannot start with a digit"
)
return dest
dest = None
for flag in flags:
cleaned = flag.lstrip("-").replace("-", "_").lower()
dest = cleaned
if flag.startswith("--"):
break
assert dest is not None, "dest should not be None"
if not dest.replace("_", "").isalnum():
raise FalyxOptionError(
f"invalid dest '{dest}': must be a valid identifier (letters, digits, and underscores only)"
)
if dest[0].isdigit():
raise FalyxOptionError(f"invalid dest '{dest}': cannot start with a digit")
return dest
def _validate_action(self, action: str | OptionAction) -> OptionAction:
if isinstance(action, OptionAction):
return action
try:
return OptionAction(action)
except ValueError as error:
raise FalyxOptionError(
f"invalid option action '{action}' is not a valid OptionAction",
hint=f"valid actions are: {', '.join(a.value for a in OptionAction)}",
) from error
def _resolve_default(
self,
default: Any,
action: OptionAction,
) -> Any:
if default is None:
if action == OptionAction.STORE_TRUE:
return False
elif action == OptionAction.STORE_FALSE:
return True
elif action == OptionAction.STORE_BOOL_OPTIONAL:
return None
elif action == OptionAction.COUNT:
return 0
elif action is OptionAction.STORE_TRUE and default is not False:
raise FalyxOptionError(
f"default value for '{action}' action must be False or None, got {default!r}"
)
elif action is OptionAction.STORE_FALSE and default is not True:
raise FalyxOptionError(
f"default value for '{action}' action must be True or None, got {default!r}"
)
elif action is OptionAction.STORE_BOOL_OPTIONAL:
raise FalyxOptionError(
f"default value for '{action}' action must be None, got {default!r}"
)
elif action in (OptionAction.HELP, OptionAction.TLDR, OptionAction.COUNT):
raise FalyxOptionError(f"default value cannot be set for action '{action}'.")
return default
def _validate_default_type(
self,
default: Any,
expected_type: Any,
dest: str,
) -> None:
if default is None:
return None
try:
coerce_value(default, expected_type)
except Exception as error:
type_name = get_type_name(expected_type)
raise FalyxOptionError(
f"invalid default value {default!r} for '{dest}' cannot be coerced to {type_name} error: {error}"
) from error
def _normalize_choices(
self,
choices: list[str] | None,
expected_type: type,
action: OptionAction,
) -> list[Any]:
if choices is None:
choices = []
else:
if action in (
OptionAction.STORE_TRUE,
OptionAction.STORE_FALSE,
OptionAction.STORE_BOOL_OPTIONAL,
):
raise FalyxOptionError(
f"choices cannot be specified for '{action}' actions"
)
if isinstance(choices, dict):
raise FalyxOptionError("choices cannot be a dict")
try:
choices = list(choices)
except TypeError as error:
raise FalyxOptionError(
"choices must be iterable (like list, tuple, or set)"
) from error
for choice in choices:
try:
coerce_value(choice, expected_type)
except Exception as error:
type_name = get_type_name(expected_type)
raise FalyxOptionError(
f"invalid choice {choice!r} cannot be coerced to {type_name} error: {error}"
) from error
return choices
def add_option(
self,
flags: tuple[str, ...],
dest: str,
action: str | OptionAction = "store",
type: type = str,
default: Any = None,
choices: list[str] | None = None,
help: str = "",
suggestions: list[str] | None = None,
) -> None:
self._validate_flags(flags)
dest = self._get_dest_from_flags(flags, dest)
if dest in self.RESERVED_DESTS:
raise FalyxOptionError(
f"invalid dest '{dest}': '{dest}' is reserved and cannot be used as an option dest"
)
if dest in self._dest_set:
raise FalyxOptionError(f"duplicate option dest '{dest}'")
action = self._validate_action(action)
default = self._resolve_default(default, action)
self._validate_default_type(default, type, dest)
choices = self._normalize_choices(choices, type, action)
if default is not None and choices and default not in choices:
choices_str = ", ".join((str(choice) for choice in choices))
raise FalyxOptionError(
f"default value {default!r} is not in allowed choices: {choices_str}"
)
if suggestions is not None and not isinstance(suggestions, list):
type_name = get_type_name(suggestions)
raise FalyxOptionError(f"suggestions must be a list or None, got {type_name}")
if isinstance(suggestions, list) and not all(
isinstance(suggestion, str) for suggestion in suggestions
):
raise FalyxOptionError("suggestions must be a list of strings")
if action is OptionAction.STORE_BOOL_OPTIONAL:
self._register_store_bool_optional(flags, dest, help)
return None
option = Option(
flags=flags,
dest=dest,
action=action,
type=type,
default=default,
choices=choices,
help=help,
suggestions=suggestions,
)
self._register_option(option)
def apply_to_options(
self,
parse_result: ParseResult,
options: OptionsManager,
) -> None:
for dest, value in parse_result.options.items():
options.set(dest, value, namespace_name=self_flx.namespace_name)
for dest, value in parse_result.root_options.items():
options.set(dest, value, namespace_name="root")
def _can_bundle_option(self, option: Option) -> bool:
return option.action in {
OptionAction.STORE_TRUE,
OptionAction.STORE_FALSE,
OptionAction.COUNT,
OptionAction.HELP,
OptionAction.TLDR,
}
def _resolve_posix_bundling(self, tokens: list[str]) -> list[str]:
"""Expand POSIX-style bundled arguments into separate arguments."""
expanded: list[str] = []
for token in tokens:
if not token.startswith("-") or token.startswith("--") or len(token) <= 2:
expanded.append(token)
continue
bundle = [f"-{char}" for char in token[1:]]
if (
all(
flag in self._options_by_dest
and self._can_bundle_option(self._options_by_dest[flag])
for flag in bundle[:-1]
)
and bundle[-1] in self._options_by_dest
):
expanded.extend(bundle)
else:
expanded.append(token)
return expanded
def _default_values(self) -> tuple[dict[str, Any], dict[str, Any]]:
values: dict[str, Any] = {}
root_values: dict[str, Any] = {}
for option in self._options:
if option.scope == OptionScope.ROOT:
root_values[option.dest] = option.default
elif option.scope == OptionScope.NAMESPACE:
values.setdefault(option.dest, option.default)
else:
assert False, f"unhandled option scope: {option.scope}"
return values, root_values
def _consume_option(
self,
option: Option,
argv: list[str],
index: int,
values: dict[str, Any],
) -> int:
match option.action:
case OptionAction.STORE_TRUE:
values[option.dest] = True
return index + 1
case OptionAction.STORE_FALSE:
values[option.dest] = False
return index + 1
case OptionAction.STORE_BOOL_OPTIONAL:
values[option.dest] = option.type(None)
return index + 1
case OptionAction.COUNT:
values[option.dest] = int(values.get(option.dest) or 0) + 1
return index + 1
case OptionAction.HELP:
values[option.dest] = True
return index + 1
case OptionAction.TLDR:
values[option.dest] = True
return index + 1
case OptionAction.STORE:
value_index = index + 1
if value_index >= len(argv):
raise FalyxOptionError(f"option '{argv[index]}' expected a value")
raw_value = argv[value_index]
try:
value = coerce_value(raw_value, option.type)
except Exception as error:
raise FalyxOptionError(
f"invalid value for '{argv[index]}': {error}"
) from error
if option.choices and value not in option.choices:
choices = ", ".join(str(choice) for choice in option.choices)
raise FalyxOptionError(
f"invalid value for '{argv[index]}': expected one of {{{choices}}}"
)
values[option.dest] = value
return index + 2
raise FalyxOptionError(f"unsupported option action: {option.action}")
def parse_args(
self,
argv: list[str] | None = None,
) -> ParseResult:
raw_argv = argv or []
arguments = self._resolve_posix_bundling(raw_argv)
values, root_values = self._default_values()
index = 0
while index < len(arguments):
token = arguments[index]
# Explicit option terminator. Everything after belongs to routing/command.
if token == "--":
index += 1
break
# First non-option is the route boundary.
if not token.startswith("-"):
break
# Unknown leading option is an error at this scope.
# This is what keeps root/namespace options honest.
option = self._options_by_dest.get(token)
if option is None:
raise FalyxOptionError(
f"unknown option '{token}' for '{self._flx.program or self._flx.title}'"
)
target_values = root_values if option.scope == OptionScope.ROOT else values
index = self._consume_option(option, arguments, index, target_values)
remaining_argv = arguments[index:]
help_requested = values.get("help", False) or values.get("tldr", False)
return ParseResult(
mode=FalyxMode.HELP if help_requested else FalyxMode.COMMAND,
raw_argv=raw_argv,
options=values,
root_options=root_values,
remaining_argv=remaining_argv,
help=values.get("help", False),
tldr=values.get("tldr", False),
current_head=remaining_argv[0] if remaining_argv else "",
)

76
falyx/parser/group.py Normal file
View File

@@ -0,0 +1,76 @@
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Argument grouping models for the Falyx command argument parser.
This module defines lightweight dataclasses used by
`CommandArgumentParser` to organize arguments into named help sections and
mutually exclusive sets.
It provides:
- `ArgumentGroup`, which represents a logical collection of related argument
destinations for grouped help rendering.
- `MutuallyExclusiveGroup`, which represents a set of argument destinations
where only one member may be selected, with optional group-level
requiredness.
These models are metadata containers only. They do not perform parsing or
validation themselves. Instead, they are populated and enforced by
`CommandArgumentParser` during argument registration, parsing, and help
generation.
This module exists to keep argument-group state explicit, structured, and easy
to introspect.
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(slots=True)
class ArgumentGroup:
"""Represents a named group of related command argument destinations.
`ArgumentGroup` is used by `CommandArgumentParser` to collect arguments that
belong together conceptually so they can be rendered under a shared section
in help output and tracked as a unit in parser metadata.
This class stores only grouping metadata and does not implement any parsing
behavior on its own.
Attributes:
name: User-facing name of the argument group.
description: Optional descriptive text for the group, typically used in
help rendering.
dests: Destination names of arguments assigned to this group.
"""
name: str
description: str = ""
dests: list[str] = field(default_factory=list)
@dataclass(slots=True)
class MutuallyExclusiveGroup:
"""Represents a mutually exclusive set of argument destinations.
`MutuallyExclusiveGroup` is used by `CommandArgumentParser` to model groups
of arguments where only one member may be provided at a time. It can also
mark the group as required, meaning that exactly one of the grouped
arguments must be present.
This class stores group metadata only. Validation and enforcement are
performed by the parser.
Attributes:
name: User-facing name of the mutually exclusive group.
required: Whether at least one argument in the group must be supplied.
description: Optional descriptive text for the group, typically used in
help rendering.
dests: Destination names of arguments assigned to this mutually
exclusive group.
"""
name: str
required: bool = False
description: str = ""
dests: list[str] = field(default_factory=list)

View File

@@ -0,0 +1,64 @@
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Parse result model for the Falyx CLI runtime.
This module defines `ParseResult`, the normalized output produced by the
root-level Falyx parsing stage.
`ParseResult` captures the session-scoped state derived from the initial
CLI parse before namespace routing or command-local argument parsing begins. It
records the selected top-level mode, the original argv, root option flags, and
any remaining argv that should be forwarded into the routed execution layer.
This model is typically produced by `FalyxParser.parse()` and then consumed by
higher-level Falyx runtime entrypoints such as `Falyx.run()` to configure
logging, prompt behavior, help rendering, and routed command dispatch.
The dataclass is intentionally lightweight and focused on root parsing only. It
does not perform parsing, validation, or execution itself.
"""
from dataclasses import dataclass, field
from typing import Any
from falyx.mode import FalyxMode
@dataclass(slots=True)
class ParseResult:
"""Represents the normalized result of root-level Falyx argument parsing.
`ParseResult` stores the outcome of the initial CLI parse that occurs at
the application boundary. It separates session-level runtime settings from
the remaining argv that should continue into namespace routing and
command-local parsing.
This model is used to communicate root parsing decisions cleanly to the
rest of the Falyx runtime, including whether the application should enter
help mode or continue with normal command execution.
Attributes:
mode: Top-level runtime mode selected from the root parse.
raw_argv: Original argv passed into the root parser.
options: Dictionary of parsed root-level options and their values.
root_options: Dictionary of parsed root-level options that should be
applied at the root level for all namespaces.
remaining_argv: Unconsumed argv that should be forwarded to routed
command resolution.
current_head: The current head token being processed (for error reporting).
help: Whether help output was requested at the root level.
tldr: Whether TLDR output was requested at the root level.
verbose: Whether verbose logging should be enabled for the session.
debug_hooks: Whether hook execution should be logged in detail.
never_prompt: Whether prompts should be suppressed for the session.
"""
mode: FalyxMode
raw_argv: list[str] = field(default_factory=list)
options: dict[str, Any] = field(default_factory=dict)
root_options: dict[str, Any] = field(default_factory=dict)
remaining_argv: list[str] = field(default_factory=list)
current_head: str = ""
help: bool = False
tldr: bool = False
verbose: bool = False
debug_hooks: bool = False
never_prompt: bool = False

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,29 +1,78 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""prompt_utils.py""" """Utilities for user interaction prompts in the Falyx CLI framework.
Provides asynchronous confirmation dialogs and helper logic to determine
whether a user should be prompted based on command-line options.
Includes:
- `should_prompt_user()` for conditional prompt logic.
- `confirm_async()` for interactive yes/no confirmation.
"""
from contextlib import contextmanager
from typing import Iterator
from prompt_toolkit import PromptSession from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import ( from prompt_toolkit.formatted_text import (
AnyFormattedText, AnyFormattedText,
FormattedText, FormattedText,
StyleAndTextTuples,
merge_formatted_text, merge_formatted_text,
) )
from rich.console import Console
from rich.text import Text
from falyx.options_manager import OptionsManager from falyx.options_manager import OptionsManager
from falyx.themes import OneColors from falyx.themes import OneColors
from falyx.validators import yes_no_validator from falyx.validators import yes_no_validator
@contextmanager
def prompt_session_context(session: PromptSession) -> Iterator[PromptSession]:
"""Temporary override for prompt session management"""
message = session.message
validator = session.validator
placeholder = session.placeholder
try:
yield session
finally:
session.message = message
session.validator = validator
session.placeholder = placeholder
def should_prompt_user( def should_prompt_user(
*, *,
confirm: bool, confirm: bool,
options: OptionsManager, options: OptionsManager,
namespace: str = "cli_args", namespace: str = "root",
): override_namespace: str = "execution",
""" ) -> bool:
Determine whether to prompt the user for confirmation based on command """Determine whether to prompt the user for confirmation.
and global options.
Checks the `confirm` flag and consults the `OptionsManager` for any relevant
flags that may override the need for confirmation, such as `--never-prompt`,
`--force-confirm`, or `--skip-confirm`. The `override_namespace` is checked
first for any explicit overrides, followed by the main `namespace` for defaults.
Args:
confirm (bool): The initial confirmation flag (e.g., from a command argument).
options (OptionsManager): The options manager to check for override flags.
namespace (str): The primary namespace to check for options (default: "root").
override_namespace (str): The secondary namespace for overrides (default: "execution").
Returns:
bool: True if the user should be prompted, False if confirmation can be bypassed.
""" """
never_prompt = options.get("never_prompt", None, override_namespace)
if never_prompt is None:
never_prompt = options.get("never_prompt", False, namespace) never_prompt = options.get("never_prompt", False, namespace)
force_confirm = options.get("force_confirm", None, override_namespace)
if force_confirm is None:
force_confirm = options.get("force_confirm", False, namespace) force_confirm = options.get("force_confirm", False, namespace)
skip_confirm = options.get("skip_confirm", None, override_namespace)
if skip_confirm is None:
skip_confirm = options.get("skip_confirm", False, namespace) skip_confirm = options.get("skip_confirm", False, namespace)
if never_prompt or skip_confirm: if never_prompt or skip_confirm:
@@ -46,3 +95,38 @@ async def confirm_async(
validator=yes_no_validator(), validator=yes_no_validator(),
) )
return answer.upper() == "Y" return answer.upper() == "Y"
def rich_text_to_prompt_text(text: Text | str | StyleAndTextTuples) -> StyleAndTextTuples:
"""Convert a Rich Text object to prompt_toolkit formatted text.
This function takes a Rich `Text` object (or a string or already formatted text)
and converts it in to a list of (style, text) tuples compatible with prompt_toolkit.
Args:
text (Text | str | StyleAndTextTuples): The input text to convert.
Returns:
StyleAndTextTuples: A list of (style, text) tuples for prompt_toolkit.
"""
if isinstance(text, list):
if all(isinstance(pair, tuple) and len(pair) == 2 for pair in text):
return text
raise TypeError("Expected list of (style, text) tuples")
if isinstance(text, str):
text = Text.from_markup(text)
if not isinstance(text, Text):
raise TypeError("Expected str, rich.text.Text, or list of (style, text) tuples")
console = Console(color_system=None, file=None, width=999, legacy_windows=False)
segments = text.render(console)
prompt_fragments: StyleAndTextTuples = []
for segment in segments:
style = segment.style or ""
string = segment.text
if string:
prompt_fragments.append((str(style), string))
return prompt_fragments

View File

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

View File

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

View File

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

95
falyx/routing.py Normal file
View File

@@ -0,0 +1,95 @@
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Routing result models for the Falyx CLI framework.
This module defines the core types used to describe the outcome of namespace
routing in a `Falyx` application.
It provides:
- `RouteKind`, an enum describing the kind of routed target that was reached,
such as a leaf command, namespace help, namespace TLDR, namespace menu, or
an unknown entry.
- `RouteResult`, a structured value object that captures the resolved routing
state, including the active namespace, invocation context, optional leaf
command, remaining argv for command-local parsing, and any suggestions for
unresolved input.
These types sit at the boundary between routing and execution. They do not
perform routing themselves. Instead, they are produced by Falyx routing logic
and then consumed by help rendering, completion, validation, preview, and
command dispatch flows.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import TYPE_CHECKING
from falyx.context import InvocationContext
from falyx.namespace import FalyxNamespace
if TYPE_CHECKING:
from falyx.command import Command
from falyx.falyx import Falyx
class RouteKind(Enum):
"""Enumerates the possible outcomes of Falyx namespace routing.
`RouteKind` identifies what the routing layer resolved the current input
to, allowing downstream code to decide whether it should execute a command,
render namespace help, show TLDR output, display a namespace menu, or
surface an unknown-entry message.
Attributes:
COMMAND: Routing reached a leaf command that may be parsed and executed.
NAMESPACE_MENU: Routing stopped at a namespace menu target.
NAMESPACE_HELP: Routing resolved to namespace help output.
NAMESPACE_TLDR: Routing resolved to namespace TLDR output.
UNKNOWN: Routing failed to resolve the requested entry.
"""
COMMAND = "command"
NAMESPACE_MENU = "namespace_menu"
NAMESPACE_HELP = "namespace_help"
NAMESPACE_TLDR = "namespace_tldr"
UNKNOWN = "unknown"
@dataclass(slots=True)
class RouteResult:
"""Represents the resolved output of a Falyx routing operation.
`RouteResult` captures the full state needed after namespace resolution
completes and before command execution or help rendering begins. It records
what kind of target was reached, where routing ended, the invocation path
used to reach it, and any leaf-command metadata needed for downstream
parsing.
This model is used by Falyx execution, help, preview, completion, and
validation flows to make routing decisions explicit and easy to inspect.
Attributes:
kind: The type of routed result that was resolved.
namespace: The `Falyx` namespace where routing ended.
context: Invocation context describing the routed path and current mode.
command: Resolved leaf command, if routing ended at a command.
namespace_entry: Resolved namespace entry, if the route corresponds to a
specific nested namespace.
leaf_argv: Remaining argv that should be delegated to the resolved
command's local parser.
current_head: The current head token that routing is evaluating, used for
generating suggestions.
suggestions: Suggested entry names for unresolved input.
is_preview: Whether the routed invocation is in preview mode.
"""
kind: RouteKind
namespace: "Falyx"
context: InvocationContext
command: "Command | None" = None
namespace_entry: FalyxNamespace | None = None
leaf_argv: list[str] = field(default_factory=list)
current_head: str = ""
suggestions: list[str] = field(default_factory=list)
is_preview: bool = False

View File

@@ -1,17 +1,29 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""selection.py""" """Provides interactive selection utilities for Falyx CLI actions.
This module defines `SelectionOption` objects, selection maps, and rich-powered
rendering functions to build interactive selection prompts using `prompt_toolkit`.
It supports:
- Grid-based and dictionary-based selection menus
- Index- or key-driven multi-select prompts
- Formatted Rich tables for CLI visual menus
- Cancel keys, defaults, and duplication control
Used by `SelectionAction` and other prompt-driven workflows within Falyx.
"""
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Callable, KeysView, Sequence from typing import Any, Callable, KeysView, Sequence
from prompt_toolkit import PromptSession from prompt_toolkit import PromptSession
from rich import box from rich import box
from rich.console import Console
from rich.markup import escape from rich.markup import escape
from rich.table import Table from rich.table import Table
from falyx.console import console
from falyx.prompt_utils import prompt_session_context, rich_text_to_prompt_text
from falyx.themes import OneColors from falyx.themes import OneColors
from falyx.utils import CaseInsensitiveDict, chunks from falyx.utils import CaseInsensitiveDict, chunks
from falyx.validators import int_range_validator, key_validator from falyx.validators import MultiIndexValidator, MultiKeyValidator
@dataclass @dataclass
@@ -33,9 +45,7 @@ class SelectionOption:
class SelectionOptionMap(CaseInsensitiveDict): class SelectionOptionMap(CaseInsensitiveDict):
""" """Manages selection options including validation and reserved key protection."""
Manages selection options including validation and reserved key protection.
"""
RESERVED_KEYS: set[str] = set() RESERVED_KEYS: set[str] = set()
@@ -105,6 +115,7 @@ def render_table_base(
highlight: bool = True, highlight: bool = True,
column_names: Sequence[str] | None = None, column_names: Sequence[str] | None = None,
) -> Table: ) -> Table:
"""Render the base table for selection prompts."""
table = Table( table = Table(
title=title, title=title,
caption=caption, caption=caption,
@@ -267,23 +278,51 @@ async def prompt_for_index(
*, *,
min_index: int = 0, min_index: int = 0,
default_selection: str = "", default_selection: str = "",
console: Console | None = None,
prompt_session: PromptSession | None = None, prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ", prompt_message: str = "Select an option > ",
show_table: bool = True, show_table: bool = True,
) -> int: number_selections: int | str = 1,
separator: str = ",",
allow_duplicates: bool = False,
cancel_key: str = "",
) -> int | list[int]:
"""Prompt the user to select an index from a table of options. Return the selected index."""
prompt_session = prompt_session or PromptSession() prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="auto")
if show_table: if show_table:
console.print(table, justify="center") console.print(table, justify="center")
selection = await prompt_session.prompt_async( number_selections_str = (
message=prompt_message, f"{number_selections} " if isinstance(number_selections, int) else ""
validator=int_range_validator(min_index, max_index),
default=default_selection,
) )
return int(selection)
plural = "s" if number_selections != 1 else ""
placeholder = (
f"Enter {number_selections_str}selection{plural} separated by '{separator}'"
if number_selections != 1
else "Enter selection"
)
with prompt_session_context(prompt_session) as session:
selection = await session.prompt_async(
message=rich_text_to_prompt_text(prompt_message),
validator=MultiIndexValidator(
min_index,
max_index,
number_selections,
separator,
allow_duplicates,
cancel_key,
),
default=default_selection,
placeholder=placeholder,
)
if selection.strip() == cancel_key:
return int(cancel_key)
if isinstance(number_selections, int) and number_selections == 1:
return int(selection.strip())
return [int(index.strip()) for index in selection.strip().split(separator)]
async def prompt_for_selection( async def prompt_for_selection(
@@ -291,35 +330,59 @@ async def prompt_for_selection(
table: Table, table: Table,
*, *,
default_selection: str = "", default_selection: str = "",
console: Console | None = None,
prompt_session: PromptSession | None = None, prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ", prompt_message: str = "Select an option > ",
show_table: bool = True, show_table: bool = True,
) -> str: number_selections: int | str = 1,
separator: str = ",",
allow_duplicates: bool = False,
cancel_key: str = "",
) -> str | list[str]:
"""Prompt the user to select a key from a set of options. Return the selected key.""" """Prompt the user to select a key from a set of options. Return the selected key."""
prompt_session = prompt_session or PromptSession() prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="auto")
if show_table: if show_table:
console.print(table, justify="center") console.print(table, justify="center")
selected = await prompt_session.prompt_async( number_selections_str = (
message=prompt_message, f"{number_selections} " if isinstance(number_selections, int) else ""
validator=key_validator(keys),
default=default_selection,
) )
return selected plural = "s" if number_selections != 1 else ""
placeholder = (
f"Enter {number_selections_str}selection{plural} separated by '{separator}'"
if number_selections != 1
else "Enter selection"
)
with prompt_session_context(prompt_session) as session:
selected = await session.prompt_async(
message=rich_text_to_prompt_text(prompt_message),
validator=MultiKeyValidator(
keys, number_selections, separator, allow_duplicates, cancel_key
),
default=default_selection,
placeholder=placeholder,
)
if selected.strip() == cancel_key:
return cancel_key
if isinstance(number_selections, int) and number_selections == 1:
return selected.strip()
return [key.strip() for key in selected.strip().split(separator)]
async def select_value_from_list( async def select_value_from_list(
title: str, title: str,
selections: Sequence[str], selections: Sequence[str],
*, *,
console: Console | None = None,
prompt_session: PromptSession | None = None, prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ", prompt_message: str = "Select an option > ",
default_selection: str = "", default_selection: str = "",
number_selections: int | str = 1,
separator: str = ",",
allow_duplicates: bool = False,
cancel_key: str = "",
columns: int = 4, columns: int = 4,
caption: str = "", caption: str = "",
box_style: box.Box = box.SIMPLE, box_style: box.Box = box.SIMPLE,
@@ -332,7 +395,7 @@ async def select_value_from_list(
title_style: str = "", title_style: str = "",
caption_style: str = "", caption_style: str = "",
highlight: bool = False, highlight: bool = False,
): ) -> str | list[str]:
"""Prompt for a selection. Return the selected item.""" """Prompt for a selection. Return the selected item."""
table = render_selection_indexed_table( table = render_selection_indexed_table(
title=title, title=title,
@@ -351,17 +414,21 @@ async def select_value_from_list(
highlight=highlight, highlight=highlight,
) )
prompt_session = prompt_session or PromptSession() prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="auto")
selection_index = await prompt_for_index( selection_index = await prompt_for_index(
len(selections) - 1, len(selections) - 1,
table, table,
default_selection=default_selection, default_selection=default_selection,
console=console,
prompt_session=prompt_session, prompt_session=prompt_session,
prompt_message=prompt_message, prompt_message=prompt_message,
number_selections=number_selections,
separator=separator,
allow_duplicates=allow_duplicates,
cancel_key=cancel_key,
) )
if isinstance(selection_index, list):
return [selections[i] for i in selection_index]
return selections[selection_index] return selections[selection_index]
@@ -369,14 +436,16 @@ async def select_key_from_dict(
selections: dict[str, SelectionOption], selections: dict[str, SelectionOption],
table: Table, table: Table,
*, *,
console: Console | None = None,
prompt_session: PromptSession | None = None, prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ", prompt_message: str = "Select an option > ",
default_selection: str = "", default_selection: str = "",
) -> Any: number_selections: int | str = 1,
separator: str = ",",
allow_duplicates: bool = False,
cancel_key: str = "",
) -> str | list[str]:
"""Prompt for a key from a dict, returns the key.""" """Prompt for a key from a dict, returns the key."""
prompt_session = prompt_session or PromptSession() prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="auto")
console.print(table, justify="center") console.print(table, justify="center")
@@ -384,9 +453,12 @@ async def select_key_from_dict(
selections.keys(), selections.keys(),
table, table,
default_selection=default_selection, default_selection=default_selection,
console=console,
prompt_session=prompt_session, prompt_session=prompt_session,
prompt_message=prompt_message, prompt_message=prompt_message,
number_selections=number_selections,
separator=separator,
allow_duplicates=allow_duplicates,
cancel_key=cancel_key,
) )
@@ -394,14 +466,16 @@ async def select_value_from_dict(
selections: dict[str, SelectionOption], selections: dict[str, SelectionOption],
table: Table, table: Table,
*, *,
console: Console | None = None,
prompt_session: PromptSession | None = None, prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ", prompt_message: str = "Select an option > ",
default_selection: str = "", default_selection: str = "",
) -> Any: number_selections: int | str = 1,
separator: str = ",",
allow_duplicates: bool = False,
cancel_key: str = "",
) -> Any | list[Any]:
"""Prompt for a key from a dict, but return the value.""" """Prompt for a key from a dict, but return the value."""
prompt_session = prompt_session or PromptSession() prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="auto")
console.print(table, justify="center") console.print(table, justify="center")
@@ -409,11 +483,16 @@ async def select_value_from_dict(
selections.keys(), selections.keys(),
table, table,
default_selection=default_selection, default_selection=default_selection,
console=console,
prompt_session=prompt_session, prompt_session=prompt_session,
prompt_message=prompt_message, prompt_message=prompt_message,
number_selections=number_selections,
separator=separator,
allow_duplicates=allow_duplicates,
cancel_key=cancel_key,
) )
if isinstance(selection_key, list):
return [selections[key].value for key in selection_key]
return selections[selection_key].value return selections[selection_key].value
@@ -421,11 +500,14 @@ async def get_selection_from_dict_menu(
title: str, title: str,
selections: dict[str, SelectionOption], selections: dict[str, SelectionOption],
*, *,
console: Console | None = None,
prompt_session: PromptSession | None = None, prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ", prompt_message: str = "Select an option > ",
default_selection: str = "", default_selection: str = "",
): number_selections: int | str = 1,
separator: str = ",",
allow_duplicates: bool = False,
cancel_key: str = "",
) -> Any | list[Any]:
"""Prompt for a key from a dict, but return the value.""" """Prompt for a key from a dict, but return the value."""
table = render_selection_dict_table( table = render_selection_dict_table(
title, title,
@@ -435,8 +517,11 @@ async def get_selection_from_dict_menu(
return await select_value_from_dict( return await select_value_from_dict(
selections=selections, selections=selections,
table=table, table=table,
console=console,
prompt_session=prompt_session, prompt_session=prompt_session,
prompt_message=prompt_message, prompt_message=prompt_message,
default_selection=default_selection, default_selection=default_selection,
number_selections=number_selections,
separator=separator,
allow_duplicates=allow_duplicates,
cancel_key=cancel_key,
) )

View File

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

245
falyx/spinner_manager.py Normal file
View File

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

View File

@@ -1,5 +1,14 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""tagged_table.py""" """Generates a Rich table view of Falyx commands grouped by their tags.
This module defines a utility function for rendering a custom CLI command
table that organizes commands into groups based on their first tag. It is
used to visually separate commands in interactive menus for better clarity
and discoverability.
Functions:
- build_tagged_table(flx): Returns a `rich.Table` of commands grouped by tag.
"""
from collections import defaultdict from collections import defaultdict
from rich import box from rich import box
@@ -15,19 +24,19 @@ def build_tagged_table(flx: Falyx) -> Table:
# Group commands by first tag # Group commands by first tag
grouped: dict[str, list[Command]] = defaultdict(list) grouped: dict[str, list[Command]] = defaultdict(list)
for cmd in flx.commands.values(): for command in flx.commands.values():
first_tag = cmd.tags[0] if cmd.tags else "Other" first_tag = command.tags[0] if command.tags else "Other"
grouped[first_tag.capitalize()].append(cmd) grouped[first_tag.capitalize()].append(command)
# Add grouped commands to table # Add grouped commands to table
for group_name, commands in grouped.items(): for group_name, commands in grouped.items():
table.add_row(f"[bold underline]{group_name} Commands[/]") table.add_row(f"[bold underline]{group_name} Commands[/]")
for cmd in commands: for command in commands:
table.add_row(f"[{cmd.key}] [{cmd.style}]{cmd.description}") table.add_row(f"[{command.key}] [{command.style}]{command.description}")
table.add_row("") table.add_row("")
# Add bottom row # Add bottom row
for row in flx.get_bottom_row(): for row in flx._get_bottom_row():
table.add_row(row) table.add_row(row)
return table return table

View File

@@ -1,7 +1,6 @@
""" """Falyx CLI Framework
Falyx CLI Framework
Copyright (c) 2025 rtj.dev LLC. Copyright (c) 2026 rtj.dev LLC.
Licensed under the MIT License. See LICENSE file for details. Licensed under the MIT License. See LICENSE file for details.
""" """

View File

@@ -1,7 +1,5 @@
""" # Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
colors.py """A Python module that integrates the Nord color palette with the Rich library.
A Python module that integrates the Nord color palette with the Rich library.
It defines a metaclass-based NordColors class allowing dynamic attribute lookups It defines a metaclass-based NordColors class allowing dynamic attribute lookups
(e.g., NORD12bu -> "#D08770 bold underline") and provides a comprehensive Nord-based (e.g., NORD12bu -> "#D08770 bold underline") and provides a comprehensive Nord-based
Theme that customizes Rich's default styles. Theme that customizes Rich's default styles.
@@ -27,8 +25,7 @@ from rich.theme import Theme
class ColorsMeta(type): class ColorsMeta(type):
""" """A metaclass that catches attribute lookups like `NORD12buidrs` or `ORANGE_b` and returns
A metaclass that catches attribute lookups like `NORD12buidrs` or `ORANGE_b` and returns
a string combining the base color + bold/italic/underline/dim/reverse/strike flags. a string combining the base color + bold/italic/underline/dim/reverse/strike flags.
The color values are required to be uppercase with optional underscores and digits, The color values are required to be uppercase with optional underscores and digits,
@@ -153,8 +150,7 @@ class OneColors(metaclass=ColorsMeta):
class NordColors(metaclass=ColorsMeta): class NordColors(metaclass=ColorsMeta):
""" """Defines the Nord color palette as class attributes.
Defines the Nord color palette as class attributes.
Each color is labeled by its canonical Nord name (NORD0-NORD15) Each color is labeled by its canonical Nord name (NORD0-NORD15)
and also has useful aliases grouped by theme: and also has useful aliases grouped by theme:
@@ -213,8 +209,7 @@ class NordColors(metaclass=ColorsMeta):
@classmethod @classmethod
def as_dict(cls): def as_dict(cls):
""" """Returns a dictionary mapping every NORD* attribute
Returns a dictionary mapping every NORD* attribute
(e.g. 'NORD0') to its hex code. (e.g. 'NORD0') to its hex code.
""" """
return { return {
@@ -225,8 +220,7 @@ class NordColors(metaclass=ColorsMeta):
@classmethod @classmethod
def aliases(cls): def aliases(cls):
""" """Returns a dictionary of *all* other aliases
Returns a dictionary of *all* other aliases
(Polar Night, Snow Storm, Frost, Aurora). (Polar Night, Snow Storm, Frost, Aurora).
""" """
skip_prefixes = ("NORD", "__") skip_prefixes = ("NORD", "__")
@@ -463,9 +457,7 @@ NORD_THEME_STYLES: dict[str, Style] = {
def get_nord_theme() -> Theme: def get_nord_theme() -> Theme:
""" """Returns a Rich Theme for the Nord color palette."""
Returns a Rich Theme for the Nord color palette.
"""
return Theme(NORD_THEME_STYLES) return Theme(NORD_THEME_STYLES)

View File

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

View File

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

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