16 Commits

Author SHA1 Message Date
efe3f5fd99 feat(core): clone commands and actions when binding runtimes
Add clone support across Action types and Command so commands can be safely
registered or runner-bound without mutating the original instances.

- clone BaseAction implementations across simple, composite, IO, prompt, file,
  HTTP, process, and signal actions
- bind cloned commands in Falyx.add_command_from_command() and CommandRunner
- preserve local never_prompt settings when cloning actions
- rename shared runtime state from options to options_manager for consistency
- seed root and execution option namespaces consistently
- apply scoped root and namespace option overrides during routing and dispatch
- improve namespace completion by delegating option suggestions to FalyxParser
- enrich missing-value errors and error hints
2026-06-07 13:04:35 -04:00
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
136 changed files with 19441 additions and 2548 deletions

1
.gitignore vendored
View File

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

View File

@@ -1,5 +1,6 @@
import asyncio
from enum import Enum
from pathlib import Path
from falyx import Falyx
from falyx.action import Action
@@ -21,13 +22,27 @@ 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,
number: int | 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}:{number} to {region} at {place}...")
return f"{service}:{tag}:{number} deployed to {region} at {place}"
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:
@@ -52,22 +67,37 @@ def default_config(parser: CommandArgumentParser) -> None:
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(
"--number",
"--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)"),
@@ -77,6 +107,40 @@ def default_config(parser: CommandArgumentParser) -> None:
)
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",
@@ -98,4 +162,30 @@ flx.add_command(
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,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.
"""

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.
"""
import asyncio
import os
import sys
from argparse import ArgumentParser, Namespace, _SubParsersAction
from pathlib import Path
from typing import Any
from falyx.config import loader
from falyx.falyx import Falyx
from falyx.parser import CommandArgumentParser, get_root_parser, get_subparsers
from falyx.parser import CommandArgumentParser
def find_falyx_config() -> Path | None:
@@ -49,71 +47,39 @@ def init_config(parser: CommandArgumentParser) -> None:
)
def init_callback(args: Namespace) -> None:
"""Callback for the init command."""
if args.command == "init":
from falyx.init import init_project
def build_bootstrap_falyx() -> Falyx:
from falyx.init import init_global, init_project
init_project(args.name)
elif args.command == "init_global":
from falyx.init import init_global
flx = Falyx()
init_global()
def get_parsers() -> tuple[ArgumentParser, _SubParsersAction]:
root_parser: ArgumentParser = get_root_parser()
subparsers = get_subparsers(root_parser)
init_parser = subparsers.add_parser(
"init",
help="Initialize a new Falyx project",
description="Create a new Falyx project with mock configuration files.",
epilog="If no name is provided, the current directory will be used.",
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.",
)
init_parser.add_argument(
"name",
type=str,
help="Name of the new Falyx project",
default=".",
nargs="?",
flx.add_command(
"G",
"Initialize Falyx global configuration",
init_global,
aliases=["init-global"],
help_text="Create a global Falyx configuration at ~/.config/falyx/.",
)
subparsers.add_parser(
"init-global",
help="Initialize Falyx global configuration",
description="Create a global Falyx configuration at ~/.config/falyx/.",
)
return root_parser, subparsers
return flx
def build_falyx() -> Falyx:
bootstrap_path = bootstrap()
if bootstrap_path:
return loader(bootstrap_path)
return build_bootstrap_falyx()
def main() -> Any:
bootstrap_path = bootstrap()
if not bootstrap_path:
from falyx.init import init_global, init_project
flx: Falyx = Falyx()
flx.add_command(
"I",
"Initialize a new Falyx project",
init_project,
aliases=["init"],
argument_config=init_config,
help_epilog="If no name is provided, the current directory will be used.",
)
flx.add_command(
"G",
"Initialize Falyx global configuration",
init_global,
aliases=["init-global"],
help_text="Create a global Falyx configuration at ~/.config/falyx/.",
)
else:
flx = loader(bootstrap_path)
root_parser, subparsers = get_parsers()
return asyncio.run(
flx.run(root_parser=root_parser, subparsers=subparsers, callback=init_callback)
)
flx = build_falyx()
return asyncio.run(flx.run())
if __name__ == "__main__":

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.
"""

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `Action`, the core atomic unit in the Falyx CLI framework, used to wrap and
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Defines `Action`, the core atomic unit in the Falyx CLI framework, used to wrap and
execute a single callable or coroutine with structured lifecycle support.
An `Action` is the simplest building block in Falyx's execution model, enabling
@@ -50,8 +49,7 @@ from falyx.utils import ensure_async
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:
- Optional retry logic.
@@ -148,8 +146,8 @@ class Action(BaseAction):
self.enable_retry()
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.
"""
return self.action, None
@@ -208,3 +206,34 @@ class Action(BaseAction):
f"retry={self.retry_policy.enabled}, "
f"rollback={self.rollback is not None})"
)
def _copy_hooks_without_retry(self) -> HookManager:
"""Create a copy of the current hooks, excluding any retry handlers."""
new_hooks = HookManager()
for hook_type, hooks in self.hooks._hooks.items():
for hook in hooks:
owner = getattr(hook, "__self__", None)
if not isinstance(owner, RetryHandler):
new_hooks.register(hook_type, hook)
return new_hooks
def clone(self) -> Action:
"""Create a copy of this Action with the same configuration."""
new_action = Action(
name=self.name,
action=self._action,
rollback=self._rollback,
args=self.args,
kwargs=self.kwargs,
hooks=self._copy_hooks_without_retry(),
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
never_prompt=self.local_never_prompt,
retry=self.retry_policy.enabled,
retry_policy=self.retry_policy.model_copy(deep=True),
spinner_message=self.spinner_message,
spinner_type=self.spinner_type,
spinner_style=self.spinner_style,
spinner_speed=self.spinner_speed,
)
return new_action

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `ActionFactory`, a dynamic Falyx Action that defers the construction of its
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""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
@@ -31,6 +30,8 @@ Example:
inject_last_result=True,
)
"""
from __future__ import annotations
from typing import Any, Callable
from rich.tree import Tree
@@ -46,8 +47,7 @@ from falyx.utils import ensure_async
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)
where the structure of the next action depends on runtime values.
@@ -176,3 +176,16 @@ class ActionFactory(BaseAction):
f"factory={self._factory.__name__ if hasattr(self._factory, '__name__') else type(self._factory).__name__}, "
f"args={self.args!r}, kwargs={self.kwargs!r})"
)
def clone(self) -> ActionFactory:
"""Return a copy of this ActionFactory with the same configuration."""
return ActionFactory(
name=self.name,
factory=self._factory,
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
args=self.args,
kwargs=self.kwargs,
preview_args=self.preview_args,
preview_kwargs=self.preview_kwargs,
)

View File

@@ -1,7 +1,6 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `ActionGroup`, a Falyx Action that executes multiple sub-actions concurrently
using asynchronous parallelism.
# 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
@@ -9,7 +8,7 @@ isolation, shared result tracking, and full lifecycle hook integration while pre
Falyx's introspectability and chaining capabilities.
Key Features:
- Executes all actions in parallel via `asyncio.gather`
- 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
@@ -27,11 +26,11 @@ Raises:
Example:
ActionGroup(
name="ParallelChecks",
name="ConcurrentChecks",
actions=[Action(...), Action(...), ChainedAction(...)],
)
This module complements `ChainedAction` by offering breadth-wise (parallel) execution
This module complements `ChainedAction` by offering breadth-wise (concurrent) execution
as opposed to depth-wise (sequential) execution.
"""
import asyncio
@@ -54,14 +53,13 @@ from falyx.themes.colors import OneColors
class ActionGroup(BaseAction, ActionListMixin):
"""
ActionGroup executes multiple actions concurrently in parallel.
"""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:
- Parallel execution of all contained actions.
- 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).
@@ -75,7 +73,7 @@ class ActionGroup(BaseAction, ActionListMixin):
Best used for:
- Batch processing multiple independent tasks.
- Reducing latency for workflows with parallelizable steps.
- Reducing latency for workflows with concurrent steps.
- Isolating errors while maximizing successful execution.
Args:
@@ -173,7 +171,7 @@ class ActionGroup(BaseAction, ActionListMixin):
combined_args = args + self.args
combined_kwargs = {**self.kwargs, **kwargs}
shared_context = SharedContext(name=self.name, action=self, is_parallel=True)
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)
@@ -229,7 +227,7 @@ class ActionGroup(BaseAction, ActionListMixin):
action.register_hooks_recursively(hook_type, hook)
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.MAGENTA_b}]⏩ ActionGroup (parallel)[/] '{self.name}'"]
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))
@@ -246,3 +244,24 @@ class ActionGroup(BaseAction, ActionListMixin):
f"inject_last_result={self.inject_last_result}, "
f"inject_into={self.inject_into})"
)
def clone(self):
"""Return a copy of this ActionGroup with the same configuration."""
cloned_actions = [
action.clone() if isinstance(action, BaseAction) else action
for action in self.actions
]
return ActionGroup(
name=self.name,
actions=cloned_actions,
args=self.args,
kwargs=self.kwargs,
hooks=self.hooks.copy(),
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
never_prompt=self.local_never_prompt,
spinner_message=self.spinner_message,
spinner_type=self.spinner_type,
spinner_style=self.spinner_style,
spinner_speed=self.spinner_speed,
)

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Provides reusable mixins for managing collections of `BaseAction` instances
# 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

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines strongly-typed enums used throughout the Falyx CLI framework for
# 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.
@@ -28,8 +27,7 @@ from enum import Enum
class FileType(Enum):
"""
Represents supported file types for reading and writing in Falyx Actions.
"""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
@@ -91,8 +89,7 @@ class FileType(Enum):
class SelectionReturnType(Enum):
"""
Controls what is returned from a `SelectionAction` when using a selection map.
"""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.
@@ -145,8 +142,7 @@ class SelectionReturnType(Enum):
class ConfirmType(Enum):
"""
Enum for defining prompt styles in confirmation dialogs.
"""Enum for defining prompt styles in confirmation dialogs.
Used by confirmation actions to control user input behavior and available choices.

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Core action system for Falyx.
# 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
@@ -14,13 +13,13 @@ Core guarantees:
- Consistent timing and execution context tracking for each run.
- Unified, predictable result handling and error propagation.
- Optional last_result injection to enable flexible, data-driven workflows.
- Built-in support for retries, rollbacks, parallel groups, chaining, and fallback
- 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 in parallel and gathers 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.
@@ -46,8 +45,7 @@ from falyx.themes import OneColors
class BaseAction(ABC):
"""
Base class for actions. Actions can be simple functions or more
"""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.
@@ -115,8 +113,8 @@ class BaseAction(ABC):
@abstractmethod
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
"""
Returns the callable to be used for argument inference.
"""Returns the callable to be used for argument inference.
By default, it returns None.
"""
raise NotImplementedError("get_infer_target must be implemented by subclasses")
@@ -127,12 +125,16 @@ class BaseAction(ABC):
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.
"""
def get_option(
self,
option_name: str,
default: Any = None,
*,
namespace_name: str = "default",
) -> Any:
"""Resolve an option from the OptionsManager if present, else default."""
if self.options_manager:
return self.options_manager.get(option_name, default)
return self.options_manager.get(option_name, default, namespace_name)
return default
@property
@@ -146,7 +148,12 @@ class BaseAction(ABC):
def never_prompt(self) -> bool:
if self._never_prompt is not None:
return self._never_prompt
return self.get_option("never_prompt", False)
return self.get_option("never_prompt", False, namespace_name="root")
@property
def local_never_prompt(self) -> bool | None:
"""Return the local never_prompt setting, which may be None if not explicitly set."""
return self._never_prompt
@property
def spinner_manager(self):
@@ -158,8 +165,8 @@ class BaseAction(ABC):
def prepare(
self, shared_context: SharedContext, options_manager: OptionsManager | None = None
) -> BaseAction:
"""
Prepare the action specifically for sequential (ChainedAction) execution.
"""Prepare the action specifically for sequential (ChainedAction) execution.
Can be overridden for chain-specific logic.
"""
self.set_shared_context(shared_context)
@@ -185,3 +192,8 @@ class BaseAction(ABC):
def __repr__(self) -> str:
return str(self)
@abstractmethod
def clone(self) -> BaseAction:
"""Return a copy of this action. Must be implemented by subclasses."""
return self

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `ChainedAction`, a core Falyx construct for executing a sequence of actions
# 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
@@ -86,8 +85,7 @@ from falyx.themes import OneColors
class ChainedAction(BaseAction, ActionListMixin):
"""
ChainedAction executes a sequence of actions one after another.
"""ChainedAction executes a sequence of actions one after another.
Features:
- Supports optional automatic last_result injection (auto_inject).
@@ -117,6 +115,7 @@ class ChainedAction(BaseAction, ActionListMixin):
name: str,
actions: (
Sequence[BaseAction | Callable[..., Any] | Callable[..., Awaitable[Any]]]
| Any
| None
) = None,
*,
@@ -276,8 +275,7 @@ class ChainedAction(BaseAction, ActionListMixin):
async def _rollback(
self, rollback_stack: list[tuple[Action, tuple[Any, ...], dict[str, Any]]]
):
"""
Roll back all executed actions in reverse order.
"""Roll back all executed actions in reverse order.
Rollbacks run even if a fallback recovered from failure,
ensuring consistent undo of all side effects.
@@ -320,3 +318,26 @@ class ChainedAction(BaseAction, ActionListMixin):
f"args={self.args!r}, kwargs={self.kwargs!r}, "
f"auto_inject={self.auto_inject}, return_list={self.return_list})"
)
def clone(self) -> ChainedAction:
"""Create a copy of this ChainedAction with the same configuration."""
cloned_actions = [
action.clone() if isinstance(action, BaseAction) else action
for action in self.actions
]
return ChainedAction(
name=self.name,
actions=cloned_actions,
args=self.args,
kwargs=self.kwargs,
hooks=self.hooks.copy(),
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
auto_inject=self.auto_inject,
return_list=self.return_list,
never_prompt=self.local_never_prompt,
spinner_message=self.spinner_message,
spinner_type=self.spinner_type,
spinner_style=self.spinner_style,
spinner_speed=self.spinner_speed,
)

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `ConfirmAction`, a Falyx Action that prompts the user for confirmation
# 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:
@@ -62,8 +61,7 @@ from falyx.validators import word_validator, words_validator
class ConfirmAction(BaseAction):
"""
Action to confirm an operation with the user.
"""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
@@ -91,14 +89,13 @@ class ConfirmAction(BaseAction):
prompt_message: str = "Confirm?",
confirm_type: ConfirmType | str = ConfirmType.YES_NO,
prompt_session: PromptSession | None = None,
never_prompt: bool = False,
never_prompt: bool | None = False,
word: str = "CONFIRM",
return_last_result: bool = False,
inject_last_result: bool = True,
inject_into: str = "last_result",
):
"""
Initialize the ConfirmAction.
"""Initialize the ConfirmAction.
Args:
message (str): The confirmation message to display.
@@ -270,3 +267,17 @@ class ConfirmAction(BaseAction):
f"ConfirmAction(name={self.name}, message={self.prompt_message}, "
f"confirm_type={self.confirm_type}, return_last_result={self.return_last_result})"
)
def clone(self) -> ConfirmAction:
"""Return a copy of this ConfirmAction with the same configuration."""
return ConfirmAction(
name=self.name,
prompt_message=self.prompt_message,
confirm_type=self.confirm_type,
prompt_session=self.prompt_session,
never_prompt=self.local_never_prompt,
word=self.word,
return_last_result=self.return_last_result,
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
)

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `FallbackAction`, a lightweight recovery Action used within `ChainedAction`
# 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`
@@ -36,6 +35,8 @@ Example:
The `FallbackAction` ensures that even if `MaybeFetchRemoteAction` fails or returns
None, `ProcessDataAction` still receives a usable input.
"""
from __future__ import annotations
from functools import cached_property
from typing import Any
@@ -46,8 +47,7 @@ from falyx.themes import OneColors
class FallbackAction(Action):
"""
FallbackAction provides a default value if the previous action failed or
"""FallbackAction provides a default value if the previous action failed or
returned None.
It injects the last result and checks:
@@ -85,3 +85,7 @@ class FallbackAction(Action):
def __str__(self) -> str:
return f"FallbackAction(fallback={self.fallback!r})"
def clone(self) -> FallbackAction:
"""Return a copy of this FallbackAction with the same fallback value."""
return FallbackAction(fallback=self.fallback)

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines an Action subclass for making HTTP requests using aiohttp within Falyx workflows.
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Defines `HTTPAction` for making HTTP requests using aiohttp.
Features:
- Automatic reuse of aiohttp.ClientSession via SharedContext
@@ -8,6 +7,9 @@ Features:
- Retry integration and last_result injection
- Clean resource teardown using hooks
"""
from __future__ import annotations
from copy import deepcopy
from typing import Any
import aiohttp
@@ -32,8 +34,7 @@ async def close_shared_http_session(context: ExecutionContext) -> None:
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
management, result injection, and lifecycle hook support. It is ideal for CLI-driven
@@ -82,6 +83,7 @@ class HTTPAction(Action):
spinner_type: str = "dots",
spinner_style: str = OneColors.CYAN,
spinner_speed: float = 1.0,
never_prompt: bool | None = None,
):
self.method = method.upper()
self.url = url
@@ -105,6 +107,7 @@ class HTTPAction(Action):
spinner_type=spinner_type,
spinner_style=spinner_style,
spinner_speed=spinner_speed,
never_prompt=never_prompt,
)
async def _request(self, *_, **__) -> dict[str, Any]:
@@ -167,3 +170,26 @@ class HTTPAction(Action):
f"data={self.data!r}, retry={self.retry_policy.enabled}, "
f"inject_last_result={self.inject_last_result})"
)
def clone(self) -> HTTPAction:
"""Return a copy of this HTTPAction with the same configuration."""
return HTTPAction(
name=self.name,
method=self.method,
url=self.url,
headers=self.headers.copy() if self.headers else None,
params=self.params.copy() if self.params else None,
json=deepcopy(self.json),
data=self.data,
hooks=self._copy_hooks_without_retry(),
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
retry=self.retry_policy.enabled,
retry_policy=self.retry_policy.model_copy(deep=True),
spinner=False,
spinner_message=self.spinner_message,
spinner_type=self.spinner_type,
spinner_style=self.spinner_style,
spinner_speed=self.spinner_speed,
never_prompt=self.local_never_prompt,
)

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
BaseIOAction: A base class for stream- or buffer-based IO-driven Actions.
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""BaseIOAction: A base class for stream- or buffer-based IO-driven Actions.
This module defines `BaseIOAction`, a specialized variant of `BaseAction`
that interacts with standard input and output, enabling command-line pipelines,
@@ -15,6 +14,8 @@ Features:
Common usage includes shell-like filters, input transformers, or any tool that
needs to consume input from another process or pipeline.
"""
from __future__ import annotations
import asyncio
import sys
from typing import Any, Callable
@@ -29,8 +30,7 @@ from falyx.themes import OneColors
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
through chained commands. It handles reading input, transforming it, and
@@ -170,3 +170,12 @@ class BaseIOAction(BaseAction):
parent.add("".join(label))
else:
self.console.print(Tree("".join(label)))
def clone(self) -> BaseIOAction:
"""Create a copy of this BaseIOAction with the same configuration."""
return self.__class__(
name=self.name,
hooks=self.hooks.copy(),
mode=self.mode,
inject_last_result=self.inject_last_result,
)

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `LiteralInputAction`, a lightweight Falyx Action that injects a static,
# 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,
@@ -43,8 +42,7 @@ from falyx.themes import OneColors
class LiteralInputAction(Action):
"""
LiteralInputAction injects a static value into a ChainedAction.
"""LiteralInputAction injects a static value into a ChainedAction.
This allows embedding hardcoded values mid-pipeline, useful when:
- Providing default or fallback inputs.
@@ -78,3 +76,7 @@ class LiteralInputAction(Action):
def __str__(self) -> str:
return f"LiteralInputAction(value={self.value!r})"
def clone(self) -> LiteralInputAction:
"""Create a copy of this LiteralInputAction with the same value."""
return LiteralInputAction(self.value)

View File

@@ -1,7 +1,6 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `LoadFileAction`, a Falyx Action for reading and parsing the contents of a file
at runtime in a structured, introspectable, and lifecycle-aware manner.
# 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—
@@ -36,6 +35,8 @@ This module is a foundational building block for file-driven CLI workflows in Fa
It is often paired with `SaveFileAction`, `SelectionAction`, or `ConfirmAction` for
robust and interactive pipelines.
"""
from __future__ import annotations
import csv
import json
import xml.etree.ElementTree as ET
@@ -57,8 +58,7 @@ from falyx.themes import OneColors
class LoadFileAction(BaseAction):
"""
LoadFileAction loads and parses the contents of a file at runtime.
"""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.
@@ -187,6 +187,7 @@ class LoadFileAction(BaseAction):
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:
@@ -243,7 +244,7 @@ class LoadFileAction(BaseAction):
for line in preview_lines:
content_tree.add(f"[dim]{line}[/]")
elif self.file_type in {FileType.JSON, FileType.YAML, FileType.TOML}:
raw = self.load_file()
raw = await self.load_file()
if raw is not None:
preview_str = (
json.dumps(raw, indent=2)
@@ -262,3 +263,14 @@ class LoadFileAction(BaseAction):
def __str__(self) -> str:
return f"LoadFileAction(file_path={self.file_path}, file_type={self.file_type})"
def clone(self) -> LoadFileAction:
"""Create a copy of this LoadFileAction with the same configuration."""
return LoadFileAction(
name=self.name,
file_path=self.file_path,
file_type=self.file_type,
encoding=self.encoding,
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
)

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `MenuAction`, a one-shot, interactive menu-style Falyx Action that presents
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""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.
@@ -37,6 +36,8 @@ Example:
This module is ideal for enabling structured, discoverable, and declarative
menus in both interactive and programmatic CLI automation.
"""
from __future__ import annotations
from typing import Any
from prompt_toolkit import PromptSession
@@ -57,8 +58,7 @@ from falyx.utils import chunks
class MenuAction(BaseAction):
"""
MenuAction displays a one-time interactive menu of predefined options,
"""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,
@@ -121,7 +121,7 @@ class MenuAction(BaseAction):
inject_last_result: bool = False,
inject_into: str = "last_result",
prompt_session: PromptSession | None = None,
never_prompt: bool = False,
never_prompt: bool | None = False,
include_reserved: bool = True,
show_table: bool = True,
custom_table: Table | None = None,
@@ -247,3 +247,21 @@ class MenuAction(BaseAction):
f"include_reserved={self.include_reserved}, "
f"prompt={'off' if self.never_prompt else 'on'})"
)
def clone(self) -> MenuAction:
"""Create a copy of this MenuAction with the same configuration."""
return MenuAction(
name=self.name,
menu_options=self.menu_options.copy(),
title=self.title,
columns=self.columns,
prompt_message=self.prompt_message,
default_selection=self.default_selection,
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
prompt_session=self.prompt_session,
never_prompt=self.local_never_prompt,
include_reserved=self.include_reserved,
show_table=self.show_table,
custom_table=self.custom_table,
)

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `ProcessAction`, a Falyx Action that executes a blocking or CPU-bound function
# 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
@@ -54,8 +53,7 @@ from falyx.themes import OneColors
class ProcessAction(BaseAction):
"""
ProcessAction runs a function in a separate process using ProcessPoolExecutor.
"""ProcessAction runs a function in a separate process using ProcessPoolExecutor.
Features:
- Executes CPU-bound or blocking tasks without blocking the main event loop.
@@ -179,3 +177,21 @@ class ProcessAction(BaseAction):
f"action={getattr(self.action, '__name__', repr(self.action))}, "
f"args={self.args!r}, kwargs={self.kwargs!r})"
)
def clone(self) -> ProcessAction:
"""Create a copy of this ProcessAction with the same configuration."""
return ProcessAction(
name=self.name,
action=self.action,
args=self.args,
kwargs=self.kwargs,
hooks=self.hooks.copy(),
executor=None,
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
never_prompt=self.local_never_prompt,
spinner_message=self.spinner_message,
spinner_type=self.spinner_type,
spinner_style=self.spinner_style,
spinner_speed=self.spinner_speed,
)

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `ProcessPoolAction`, a parallelized action executor that distributes
# 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
@@ -37,8 +36,7 @@ from falyx.themes import OneColors
@dataclass
class ProcessTask:
"""
Represents a callable task with its arguments for parallel execution.
"""Represents a callable task with its arguments for parallel execution.
This lightweight container is used to queue individual tasks for execution
inside a `ProcessPoolAction`.
@@ -60,10 +58,17 @@ class ProcessTask:
if not callable(self.task):
raise TypeError(f"Expected a callable task, got {type(self.task).__name__}")
def copy(self) -> ProcessTask:
"""Create a copy of this ProcessTask."""
return ProcessTask(
task=self.task,
args=self.args,
kwargs=self.kwargs.copy(),
)
class ProcessPoolAction(BaseAction):
"""
Executes a set of independent tasks in parallel using a process pool.
"""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
@@ -147,7 +152,7 @@ class ProcessPoolAction(BaseAction):
async def _run(self, *args, **kwargs) -> Any:
if not self.actions:
raise EmptyPoolError(f"[{self.name}] No actions to execute.")
shared_context = SharedContext(name=self.name, action=self, is_parallel=True)
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:
@@ -233,3 +238,15 @@ class ProcessPoolAction(BaseAction):
f"inject_last_result={self.inject_last_result}, "
f"inject_into={self.inject_into!r})"
)
def clone(self) -> ProcessPoolAction:
"""Create a copy of this ProcessPoolAction with the same configuration."""
cloned = ProcessPoolAction(
name=self.name,
actions=[action.copy() for action in self.actions],
hooks=self.hooks.copy(),
executor=None,
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
)
return cloned

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `PromptMenuAction`, a Falyx Action that prompts the user to choose from
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Defines `PromptMenuAction`, a Falyx Action that prompts the user to choose from
a list of labeled options using a single-line prompt input. Each option corresponds
to a `MenuOption` that wraps a description and an executable action.
@@ -11,6 +10,8 @@ or contextual user input flows.
Key Components:
- PromptMenuAction: Inline prompt-driven menu runner
"""
from __future__ import annotations
from typing import Any
from prompt_toolkit import PromptSession
@@ -29,8 +30,7 @@ from falyx.themes import OneColors
class PromptMenuAction(BaseAction):
"""
Displays a single-line interactive prompt for selecting an option from a menu.
"""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
@@ -189,3 +189,17 @@ class PromptMenuAction(BaseAction):
f"include_reserved={self.include_reserved}, "
f"prompt={'off' if self.never_prompt else 'on'})"
)
def clone(self) -> PromptMenuAction:
"""Create a copy of this PromptMenuAction with the same configuration."""
return PromptMenuAction(
name=self.name,
menu_options=self.menu_options.copy(),
prompt_message=self.prompt_message,
default_selection=self.default_selection,
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
prompt_session=self.prompt_session,
never_prompt=self.never_prompt,
include_reserved=self.include_reserved,
)

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `SaveFileAction`, a Falyx Action for writing structured or unstructured data
# 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
@@ -20,6 +19,8 @@ Common use cases:
- Logging artifacts from batch pipelines
- Exporting config or user input to JSON/YAML for reuse
"""
from __future__ import annotations
import csv
import json
import xml.etree.ElementTree as ET
@@ -41,8 +42,7 @@ from falyx.themes import OneColors
class SaveFileAction(BaseAction):
"""
Saves data to a file in the specified format.
"""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
@@ -91,7 +91,7 @@ class SaveFileAction(BaseAction):
def __init__(
self,
name: str,
file_path: str,
file_path: str | Path | None,
file_type: FileType | str = FileType.TEXT,
mode: Literal["w", "a"] = "w",
encoding: str = "UTF-8",
@@ -100,9 +100,9 @@ class SaveFileAction(BaseAction):
create_dirs: bool = True,
inject_last_result: bool = False,
inject_into: str = "data",
never_prompt: bool | None = False,
):
"""
SaveFileAction allows saving data to a file.
"""SaveFileAction allows saving data to a file.
Args:
name (str): Name of the action.
@@ -115,9 +115,13 @@ class SaveFileAction(BaseAction):
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.
never_prompt (bool | None): Whether to never prompt for input.
"""
super().__init__(
name=name, inject_last_result=inject_last_result, inject_into=inject_into
name=name,
inject_last_result=inject_last_result,
inject_into=inject_into,
never_prompt=never_prompt,
)
self._file_path = self._coerce_file_path(file_path)
self._file_type = FileType(file_type)
@@ -294,3 +298,19 @@ class SaveFileAction(BaseAction):
def __str__(self) -> str:
return f"SaveFileAction(file_path={self.file_path}, file_type={self.file_type})"
def clone(self) -> SaveFileAction:
"""Create a copy of this SaveFileAction with the same configuration."""
return SaveFileAction(
name=self.name,
file_path=self.file_path,
file_type=self.file_type,
mode=self.mode,
encoding=self.encoding,
data=self.data,
overwrite=self.overwrite,
create_dirs=self.create_dirs,
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
never_prompt=self.local_never_prompt,
)

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `SelectFileAction`, a Falyx Action that allows users to select one or more
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""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`.
@@ -72,8 +71,7 @@ from falyx.themes import OneColors
class SelectFileAction(BaseAction):
"""
SelectFileAction allows users to select a file(s) from a directory and return:
"""SelectFileAction allows users to select a file(s) from a directory and return:
- file content (as text, JSON, CSV, etc.)
- or the file path itself.
@@ -115,8 +113,9 @@ class SelectFileAction(BaseAction):
separator: str = ",",
allow_duplicates: bool = False,
prompt_session: PromptSession | None = None,
never_prompt: bool | None = False,
):
super().__init__(name)
super().__init__(name, never_prompt=never_prompt)
self.directory = Path(directory).resolve()
self.title = title
self.columns = columns
@@ -185,6 +184,9 @@ class SelectFileAction(BaseAction):
raise ValueError(f"Unsupported return type: {self.return_type}")
except Exception as error:
logger.error("Failed to parse %s: %s", file.name, error)
raise ValueError(
f"Failed to parse {file.name} as {self.return_type}: {error}"
) from error
return value
def _find_cancel_key(self, options) -> str:
@@ -292,3 +294,22 @@ class SelectFileAction(BaseAction):
f"SelectFileAction(name={self.name!r}, dir={str(self.directory)!r}, "
f"suffix_filter={self.suffix_filter!r}, return_type={self.return_type})"
)
def clone(self) -> SelectFileAction:
"""Create a copy of this SelectFileAction with the same configuration."""
return SelectFileAction(
name=self.name,
directory=self.directory,
title=self.title,
columns=self.columns,
prompt_message=self.prompt_message,
style=self.style,
suffix_filter=self.suffix_filter,
return_type=self.return_type,
encoding=self.encoding,
number_selections=self.number_selections,
separator=self.separator,
allow_duplicates=self.allow_duplicates,
prompt_session=self.prompt_session,
never_prompt=self.local_never_prompt,
)

View File

@@ -1,7 +1,6 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `SelectionAction`, a highly flexible Falyx Action for interactive or headless
selection from a list or dictionary of user-defined options.
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Defines `SelectionAction`, a highly flexible Falyx Action for interactive or
headless selection from a list or dictionary of user-defined options.
This module powers workflows that require prompting the user for input, selecting
configuration presets, branching execution paths, or collecting multiple values
@@ -31,6 +30,8 @@ Example:
This module is foundational to creating expressive, user-centered CLI experiences
within Falyx while preserving reproducibility and automation friendliness.
"""
from __future__ import annotations
from typing import Any
from prompt_toolkit import PromptSession
@@ -56,9 +57,8 @@ from falyx.themes import OneColors
class SelectionAction(BaseAction):
"""
A Falyx Action for interactively or programmatically selecting one or more items
from a list or dictionary of options.
"""A Falyx Action for interactively or programmatically selecting one or more
items from a list or dictionary of options.
`SelectionAction` supports both `list[str]` and `dict[str, SelectionOption]`
inputs. It renders a prompt (unless `never_prompt=True`), validates user input
@@ -90,7 +90,12 @@ class SelectionAction(BaseAction):
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.
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.
@@ -135,7 +140,7 @@ class SelectionAction(BaseAction):
inject_into: str = "last_result",
return_type: SelectionReturnType | str = "value",
prompt_session: PromptSession | None = None,
never_prompt: bool = False,
never_prompt: bool | None = False,
show_table: bool = True,
):
super().__init__(
@@ -344,7 +349,7 @@ class SelectionAction(BaseAction):
selection = [
key
for key, sel in self.selections.items()
if sel.value == maybe_result
if sel.value == maybe_result and maybe_result is not None
]
if selection:
effective_default = selection[0]
@@ -553,3 +558,23 @@ class SelectionAction(BaseAction):
f"return_type={self.return_type!r}, "
f"prompt={'off' if self.never_prompt else 'on'})"
)
def clone(self) -> SelectionAction:
"""Create a copy of this SelectionAction with the same configuration."""
return SelectionAction(
name=self.name,
selections=self.selections.copy(),
title=self.title,
columns=self.columns,
prompt_message=self.prompt_message,
default_selection=self.default_selection,
number_selections=self.number_selections,
separator=self.separator,
allow_duplicates=self.allow_duplicates,
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
return_type=self.return_type,
prompt_session=self.prompt_session,
never_prompt=self.local_never_prompt,
show_table=self.show_table,
)

View File

@@ -1,4 +1,4 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Execute shell commands with input substitution."""
from __future__ import annotations
@@ -16,8 +16,7 @@ from falyx.themes import OneColors
class ShellAction(BaseIOAction):
"""
ShellAction wraps a shell command template for CLI pipelines.
"""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
@@ -102,3 +101,15 @@ class ShellAction(BaseIOAction):
f"ShellAction(name={self.name!r}, command_template={self.command_template!r},"
f" safe_mode={self.safe_mode})"
)
def clone(self) -> ShellAction:
"""Create a copy of this ShellAction with the same configuration."""
return ShellAction(
name=self.name,
command_template=self.command_template,
safe_mode=self.safe_mode,
mode=self.mode,
hooks=self.hooks.copy(),
inject_last_result=self.inject_last_result,
inject_into=self.inject_into,
)

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `SignalAction`, a lightweight Falyx Action that raises a `FlowSignal`
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""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.
@@ -24,6 +23,8 @@ Use Cases:
Example:
SignalAction("ExitApp", QuitSignal(), hooks=my_hook_manager)
"""
from __future__ import annotations
from rich.tree import Tree
from falyx.action.action import Action
@@ -33,8 +34,7 @@ from falyx.themes import OneColors
class SignalAction(Action):
"""
A hook-compatible action that raises a control flow signal when invoked.
"""A hook-compatible action that raises a control flow signal when invoked.
`SignalAction` raises a `FlowSignal` (e.g., `BackSignal`, `QuitSignal`,
`BreakChainSignal`) during execution. It is commonly used to exit menus,
@@ -59,8 +59,7 @@ class SignalAction(Action):
super().__init__(name, action=self.raise_signal, hooks=hooks)
async def raise_signal(self, *args, **kwargs):
"""
Raises the configured `FlowSignal`.
"""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.
@@ -74,8 +73,7 @@ class SignalAction(Action):
@signal.setter
def signal(self, value: FlowSignal):
"""
Validates that the provided value is a `FlowSignal`.
"""Validates that the provided value is a `FlowSignal`.
Raises:
TypeError: If `value` is not an instance of `FlowSignal`.
@@ -94,3 +92,7 @@ class SignalAction(Action):
tree = parent.add(label) if parent else Tree(label)
if not parent:
self.console.print(tree)
def clone(self) -> SignalAction:
"""Creates a copy of this SignalAction with the same configuration."""
return SignalAction(name=self.name, signal=self.signal, hooks=self.hooks.copy())

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `UserInputAction`, a Falyx Action that prompts the user for input using
# 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.
@@ -26,6 +25,8 @@ Example:
validator=Validator.from_callable(lambda s: len(s) > 0),
)
"""
from __future__ import annotations
from prompt_toolkit import PromptSession
from prompt_toolkit.validation import Validator
from rich.tree import Tree
@@ -40,8 +41,7 @@ from falyx.themes.colors import OneColors
class UserInputAction(BaseAction):
"""
Prompts the user for textual input and returns their response.
"""Prompts the user for textual input and returns their response.
`UserInputAction` uses Prompt Toolkit to gather input with optional validation,
lifecycle hook compatibility, and support for default text. If `inject_last_result`
@@ -134,3 +134,15 @@ class UserInputAction(BaseAction):
def __str__(self):
return f"UserInputAction(name={self.name!r}, prompt={self.prompt!r})"
def clone(self) -> UserInputAction:
"""Creates a copy of this UserInputAction with the same configuration."""
return UserInputAction(
name=self.name,
prompt_message=self.prompt_message,
default_text=self.default_text,
multiline=self.multiline,
validator=self.validator,
prompt_session=self.prompt_session,
inject_last_result=self.inject_last_result,
)

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Provides the `BottomBar` class for managing a customizable bottom status bar in
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""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:
@@ -21,12 +20,14 @@ Key Features:
- Columnar layout with automatic width scaling
- Optional integration with `OptionsManager` for dynamic state toggling
Usage Example:
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.
@@ -70,6 +71,11 @@ class BottomBar:
self.toggle_keys: list[str] = []
self.key_bindings = key_bindings or KeyBindings()
@property
def has_items(self) -> bool:
"""Check if the bottom bar has any registered items."""
return bool(self._named_items)
@staticmethod
def default_render(label: str, value: Any, fg: str, bg: str, width: int) -> HTML:
return HTML(f"<style fg='{fg}' bg='{bg}'>{label}: {value:^{width}}</style>")
@@ -200,7 +206,7 @@ class BottomBar:
label: str,
options: OptionsManager,
option_name: str,
namespace_name: str = "cli_args",
namespace_name: str = "default",
fg: str = OneColors.BLACK,
bg_on: str = OneColors.GREEN,
bg_off: str = OneColors.DARK_RED,

View File

@@ -1,19 +1,43 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines the Command class for Falyx CLI.
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Command abstraction for the Falyx CLI framework.
Commands are callable units representing a menu option or CLI task,
wrapping either a BaseAction or a simple function. They provide:
This module defines the `Command` class, which represents a single executable
unit exposed to users via CLI or interactive menu interfaces.
- Hook lifecycle (before, on_success, on_error, after, on_teardown)
A `Command` acts as a bridge between:
- User input (parsed via CommandArgumentParser)
- Execution logic (encapsulated in Action / BaseAction)
- Runtime configuration (OptionsManager)
- Lifecycle hooks (HookManager)
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
- Retry logic (single action or recursively through action trees)
- 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
in building robust interactive menus.
Execution Model:
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
@@ -22,17 +46,20 @@ from typing import Any, Awaitable, Callable
from prompt_toolkit.formatted_text import FormattedText
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
from rich.style import Style
from rich.tree import Tree
from falyx.action.action import Action
from falyx.action.base_action import BaseAction
from falyx.console import console
from falyx.context import ExecutionContext
from falyx.context import ExecutionContext, InvocationContext
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.hook_manager import HookManager, HookType
from falyx.hooks import spinner_before_hook, spinner_teardown_hook
from falyx.logger import logger
from falyx.mode import FalyxMode
from falyx.options_manager import OptionsManager
from falyx.parser.command_argument_parser import CommandArgumentParser
from falyx.parser.signature import infer_args_from_func
@@ -46,67 +73,104 @@ from falyx.utils import ensure_async
class Command(BaseModel):
"""
Represents a selectable command in a Falyx menu system.
"""Represents a user-invokable command in Falyx.
A Command wraps an executable action (function, coroutine, or BaseAction)
and enhances it with:
A `Command` encapsulates all metadata, parsing logic, and execution behavior
required to expose a callable workflow through the Falyx CLI or interactive
menu system.
- Lifecycle hooks (before, success, error, after, teardown)
- Retry support (single action or recursive for chained/grouped actions)
- Confirmation prompts for safe execution
- Spinner visuals during execution
- Tagging for categorization and filtering
- Rich-based CLI previews
It is responsible for:
- Identifying the command via key and aliases
- Binding an executable Action or callable
- Parsing user-provided arguments
- Managing execution configuration (retries, confirmation, etc.)
- 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
Commands are built to be flexible yet robust, enabling dynamic CLI workflows
without sacrificing control or reliability.
Help & Introspection:
- Provides usage, help text, and TLDR examples
- Supports both CLI help and interactive menu rendering
- Can expose simplified or full help signatures
Attributes:
key (str): Primary trigger key for the command.
Args:
key (str): Primary identifier used to invoke the command.
description (str): Short description for the menu display.
hidden (bool): Toggles visibility in the menu.
aliases (list[str]): Alternate keys or phrases.
action (BaseAction | Callable): The executable logic.
args (tuple): Static positional arguments.
kwargs (dict): Static keyword arguments.
help_text (str): Additional help or guidance text.
style (str): Rich style for description.
confirm (bool): Whether to require confirmation before executing.
confirm_message (str): Custom confirmation prompt.
preview_before_confirm (bool): Whether to preview before confirming.
spinner (bool): Whether to show a spinner during execution.
spinner_message (str): Spinner text message.
spinner_type (str): Spinner style (e.g., dots, line, etc.).
spinner_style (str): Color or style of the spinner.
spinner_speed (float): Speed of the spinner animation.
hooks (HookManager): Hook manager for lifecycle events.
retry (bool): Enable retry on failure.
retry_all (bool): Enable retry across chained or grouped actions.
retry_policy (RetryPolicy): Retry behavior configuration.
tags (list[str]): Organizational tags for the command.
logging_hooks (bool): Whether to attach logging hooks automatically.
options_manager (OptionsManager): Manages global command-line options.
arg_parser (CommandArgumentParser): Parses command arguments.
arguments (list[dict[str, Any]]): Argument definitions for the command.
argument_config (Callable[[CommandArgumentParser], None] | None): Function to configure arguments
for the command parser.
custom_parser (ArgParserProtocol | None): Custom argument parser.
custom_help (Callable[[], str | None] | None): Custom help message generator.
auto_args (bool): Automatically infer arguments from the action.
arg_metadata (dict[str, str | dict[str, Any]]): Metadata for arguments,
such as help text or choices.
simple_help_signature (bool): Whether to use a simplified help signature.
ignore_in_history (bool): Whether to ignore this command in execution history last result.
program: (str | None): The parent program name.
action (BaseAction | Callable[..., Any]):
Execution logic for the command.
args (tuple, optional): Static positional arguments.
kwargs (dict[str, Any], optional): Static keyword arguments.
hidden (bool): Whether to hide the command from menus.
aliases (list[str], optional): Alternate names for invocation.
help_text (str): Help description shown in CLI/menu.
help_epilog (str): Additional help content.
style (Style | str): Rich style used for rendering.
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): Enable spinner during execution.
spinner_message (str): Spinner message text.
spinner_type (str): Rich Spinner animation type (e.g., dots, line, etc.).
spinner_style (Style | str): Rich style for the spinner.
spinner_speed (float): Spinner speed multiplier.
hooks (HookManager | None): Hook manager for lifecycle events.
tags (list[str], optional): Tags for grouping and filtering.
logging_hooks (bool): Enable debug logging hooks.
retry (bool): Enable retry behavior.
retry_all (bool): Apply retry to all nested actions.
retry_policy (RetryPolicy | None): Retry configuration.
arg_parser (CommandArgumentParser | None):
Custom argument parser instance.
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:
__call__(): Executes the command, respecting hooks and retries.
preview(): Rich tree preview of the command.
confirmation_prompt(): Formatted prompt for confirmation.
result: Property exposing the last result.
log_summary(): Summarizes execution details to the console.
Raises:
CommandArgumentError: If argument parsing fails.
InvalidActionError: If action is not callable or invalid.
FalyxError: If command configuration is invalid.
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
@@ -118,16 +182,16 @@ class Command(BaseModel):
aliases: list[str] = Field(default_factory=list)
help_text: str = ""
help_epilog: str = ""
style: str = OneColors.WHITE
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: str = OneColors.CYAN
spinner_style: Style | str = OneColors.CYAN
spinner_speed: float = 1.0
hooks: "HookManager" = Field(default_factory=HookManager)
hooks: HookManager = Field(default_factory=HookManager)
retry: bool = False
retry_all: bool = False
retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
@@ -135,10 +199,13 @@ class Command(BaseModel):
logging_hooks: bool = False
options_manager: OptionsManager = Field(default_factory=OptionsManager)
arg_parser: CommandArgumentParser | None = None
execution_options: frozenset[ExecutionOption] = Field(default_factory=frozenset)
arguments: list[dict[str, Any]] = Field(default_factory=list)
argument_config: Callable[[CommandArgumentParser], None] | None = None
custom_parser: ArgParserProtocol | None = None
custom_help: Callable[[], str | None] | None = None
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]] = Field(default_factory=dict)
simple_help_signature: bool = False
@@ -149,52 +216,106 @@ class Command(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
async def parse_args(
self, raw_args: list[str] | str, from_validate: bool = False
) -> tuple[tuple, dict]:
if callable(self.custom_parser):
async def resolve_args(
self,
raw_args: list[str] | str,
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):
try:
raw_args = shlex.split(raw_args)
except ValueError:
logger.warning(
"[Command:%s] Failed to split arguments: %s",
self.key,
raw_args,
)
return ((), {})
except ValueError as error:
raise CommandArgumentError(
f"[{self.key}] Failed to parse arguments: {error}"
) from error
return self.custom_parser(raw_args)
if isinstance(raw_args, str):
try:
raw_args = shlex.split(raw_args)
except ValueError:
logger.warning(
"[Command:%s] Failed to split arguments: %s",
self.key,
raw_args,
)
return ((), {})
if not isinstance(self.arg_parser, CommandArgumentParser):
logger.warning(
"[Command:%s] No argument parser configured, using default parsing.",
self.key,
except ValueError as error:
raise CommandArgumentError(
f"[{self.key}] Failed to parse arguments: {error}"
) from error
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(
raw_args, from_validate=from_validate
raw_args,
from_validate=from_validate,
invocation_context=invocation_context,
)
@field_validator("action", mode="before")
@classmethod
def wrap_callable_as_async(cls, action: Any) -> Any:
def _wrap_callable_as_async(cls, action: Any) -> Any:
"""Ensure the action is an async callable or a BaseAction instance."""
if isinstance(action, BaseAction):
return action
elif callable(action):
return ensure_async(action)
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]]:
"""Retrieve the argument definitions for the command."""
if self.arguments:
return self.arguments
elif callable(self.argument_config) and isinstance(
@@ -246,9 +367,15 @@ class Command(BaseModel):
program=self.program,
options_manager=self.options_manager,
)
for arg_def in self.get_argument_definitions():
for arg_def in self._get_argument_definitions():
self.arg_parser.add_argument(*arg_def.pop("flags"), **arg_def)
if isinstance(self.arg_parser, CommandArgumentParser) and self.execution_options:
self.arg_parser.enable_execution_options(self.execution_options)
if isinstance(self.arg_parser, CommandArgumentParser):
self.arg_parser.set_options_manager(self.options_manager)
if self.ignore_in_history and isinstance(self.action, BaseAction):
self.action.ignore_in_history = True
@@ -257,10 +384,58 @@ class Command(BaseModel):
if isinstance(self.action, BaseAction):
self.action.set_options_manager(self.options_manager)
async def _handle_prompt_user(self) -> None:
"""Handle user confirmation prompts based on command configuration and options."""
action_never_prompt = None
if isinstance(self.action, BaseAction):
action_never_prompt = self.action.local_never_prompt
if should_prompt_user(
confirm=self.confirm,
options=self.options_manager,
action_never_prompt=action_never_prompt,
):
if self.preview_before_confirm:
await self.preview()
if not await confirm_async(self._confirmation_prompt):
logger.info("[Command:%s] Cancelled by user.", self.key)
raise CancelSignal(f"[Command:{self.key}] Cancelled by confirmation.")
async def __call__(self, *args, **kwargs) -> Any:
"""
Run the action with full hook lifecycle, timing, error handling,
confirmation prompts, preview, and spinner integration.
"""Execute the command's underlying action with lifecycle management.
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()
combined_args = args + self.args
@@ -273,12 +448,7 @@ class Command(BaseModel):
)
self._context = context
if should_prompt_user(confirm=self.confirm, options=self.options_manager):
if self.preview_before_confirm:
await self.preview()
if not await confirm_async(self.confirmation_prompt):
logger.info("[Command:%s] Cancelled by user.", self.key)
raise CancelSignal(f"[Command:{self.key}] Cancelled by confirmation.")
await self._handle_prompt_user()
context.start_timer()
@@ -305,7 +475,7 @@ class Command(BaseModel):
return self._context.result if self._context else None
@property
def confirmation_prompt(self) -> FormattedText:
def _confirmation_prompt(self) -> FormattedText:
"""Generate a styled prompt_toolkit FormattedText confirmation message."""
if self.confirm_message and self.confirm_message != "Are you sure?":
return FormattedText([("class:confirm", self.confirm_message)])
@@ -329,29 +499,71 @@ class Command(BaseModel):
return FormattedText(prompt)
def get_option(
self,
option_name: str,
default: Any = None,
*,
namespace_name: str = "default",
) -> Any:
"""Resolve an option from the OptionsManager if present, else default."""
if self.options_manager:
return self.options_manager.get(option_name, default, namespace_name)
return default
@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(plain_text=True)
options_text = self.arg_parser.get_options_text(plain_text=True)
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) -> tuple[str, str, str]:
"""Generate a help signature for the command."""
is_cli_mode = self.options_manager.get("mode") in {
FalyxMode.RUN,
FalyxMode.PREVIEW,
FalyxMode.RUN_ALL,
}
def help_signature(
self,
invocation_context: InvocationContext | None = None,
) -> tuple[str, str, str]:
"""Return a formatted help signature for display.
program = f"{self.program} run " if is_cli_mode else ""
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 = f"[{self.style}]{program}[/]{self.arg_parser.get_usage()}"
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]"
@@ -364,16 +576,29 @@ class Command(BaseModel):
+ [f"[{self.style}]{alias}[/{self.style}]" for alias in self.aliases]
)
return (
f"[{self.style}]{program}[/]{command_keys}",
f"[dim]{self.description}[/dim]",
f"{command_keys}",
f"[dim]{self.help_text or self.description}[/dim]",
"",
)
def log_summary(self) -> None:
"""Log a summary of the command execution if context is available."""
if self._context:
self._context.log_summary()
def render_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."""
if callable(self.custom_help):
output = self.custom_help()
@@ -381,11 +606,24 @@ class Command(BaseModel):
console.print(output)
return True
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 False
async def preview(self) -> None:
"""Preview the command execution."""
label = f"[{OneColors.GREEN_b}]Command:[/] '{self.key}'{self.description}"
if hasattr(self.action, "preview") and callable(self.action.preview):
@@ -415,3 +653,360 @@ class Command(BaseModel):
f"Command(key='{self.key}', description='{self.description}' "
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
def clone_with_overrides(
self,
*,
key: str | None = None,
description: str | None = None,
action: BaseAction | Callable[..., Any] | None = None,
args: tuple | None = None,
kwargs: dict[str, Any] | None = None,
hidden: bool | None = None,
aliases: list[str] | None = None,
help_text: str | None = None,
help_epilog: str | None = None,
style: Style | str | None = None,
confirm: bool | None = None,
confirm_message: str | None = None,
preview_before_confirm: bool | None = None,
spinner: bool | None = None,
spinner_message: str | None = None,
spinner_type: str | None = None,
spinner_style: Style | str | None = None,
spinner_speed: float | None = None,
hooks: HookManager | None = None,
retry: bool | None = None,
retry_all: bool | None = None,
retry_policy: RetryPolicy | None = None,
tags: list[str] | None = None,
logging_hooks: bool | None = None,
options_manager: OptionsManager | None = None,
arg_parser: CommandArgumentParser | None = None,
execution_options: list[ExecutionOption | str] | None = None,
arguments: list[dict[str, Any]] | None = None,
argument_config: Callable[[CommandArgumentParser], None] | 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 | None = None,
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
simple_help_signature: bool | None = None,
ignore_in_history: bool | None = None,
program: str | None = None,
) -> Command:
"""Create a clone of the command with specified overrides."""
if not arg_parser and self.arg_parser:
arg_parser = self.arg_parser.clone_with_overrides(
command_key=key or self.key,
command_description=description or self.description,
command_style=style or self.style,
help_text=help_text or self.help_text,
help_epilog=help_epilog or self.help_epilog,
aliases=aliases if aliases is not None else self.aliases,
program=program or self.program,
options_manager=options_manager or self.options_manager,
)
if not hooks and self.hooks:
hooks = self.hooks.copy()
if not action and self.action:
if isinstance(self.action, BaseAction):
cloned_action: (
BaseAction | Callable[..., Any] | Callable[..., Awaitable[Any]]
) = self.action.clone()
elif callable(self.action):
cloned_action = self.action
else:
raise NotAFalyxError("Action must be a BaseAction or callable to clone.")
return Command.build(
key=key or self.key,
description=description or self.description,
action=action or cloned_action,
args=args if args is not None else self.args,
kwargs=kwargs if kwargs is not None else self.kwargs,
hidden=hidden if hidden is not None else self.hidden,
aliases=aliases if aliases is not None else self.aliases,
help_text=help_text if help_text is not None else self.help_text,
help_epilog=help_epilog if help_epilog is not None else self.help_epilog,
style=style or self.style,
confirm=confirm if confirm is not None else self.confirm,
confirm_message=confirm_message or self.confirm_message,
preview_before_confirm=(
preview_before_confirm
if preview_before_confirm is not None
else self.preview_before_confirm
),
spinner=spinner if spinner is not None else self.spinner,
spinner_message=spinner_message or self.spinner_message,
spinner_type=spinner_type or self.spinner_type,
spinner_style=spinner_style or self.spinner_style,
spinner_speed=(
spinner_speed if spinner_speed is not None else self.spinner_speed
),
hooks=hooks or self.hooks,
retry=retry if retry is not None else self.retry,
retry_all=retry_all if retry_all is not None else self.retry_all,
retry_policy=retry_policy or self.retry_policy,
tags=tags if tags is not None else self.tags,
logging_hooks=(
logging_hooks if logging_hooks is not None else self.logging_hooks
),
options_manager=options_manager or self.options_manager,
arg_parser=arg_parser or self.arg_parser,
execution_options=(
execution_options
if execution_options is not None
else (list(self.execution_options) if self.execution_options else [])
),
arguments=arguments if arguments is not None else (self.arguments or []),
argument_config=argument_config or self.argument_config,
custom_parser=custom_parser or self.custom_parser,
custom_help=custom_help or self.custom_help,
custom_tldr=custom_tldr or self.custom_tldr,
custom_usage=custom_usage or self.custom_usage,
auto_args=auto_args if auto_args is not None else self.auto_args,
arg_metadata=(
arg_metadata if arg_metadata is not None else (self.arg_metadata or {})
),
simple_help_signature=(
simple_help_signature
if simple_help_signature is not None
else self.simple_help_signature
),
ignore_in_history=(
ignore_in_history
if ignore_in_history is not None
else self.ignore_in_history
),
program=program or self.program,
)

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_manager (OptionsManager): Shared options manager used to apply scoped
execution overrides.
hooks (HookManager): Hook manager for executor-level lifecycle hooks.
"""
def __init__(
self,
*,
options_manager: OptionsManager,
hooks: HookManager,
) -> None:
self.options_manager = options_manager
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_manager.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

543
falyx/command_runner.py Normal file
View File

@@ -0,0 +1,543 @@
# 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_manager (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_manager: 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_manager (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_manager = self._get_options_manager(options_manager)
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_manager
if program:
self.command.program = program
if isinstance(self.command.arg_parser, CommandArgumentParser):
self.command.arg_parser.set_options_manager(self.options_manager)
self.command.arg_parser.is_runner_mode = True
if program:
self.command.arg_parser.program = program
self.executor = CommandExecutor(
options_manager=self.options_manager,
hooks=self.runner_hooks,
)
if not self.options_manager.get_namespace("root"):
self.options_manager.from_mapping(values={}, namespace_name="root")
if not self.options_manager.get_namespace("execution"):
self.options_manager.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_manager(
self,
options_manager: OptionsManager | None,
) -> OptionsManager:
if options_manager is None:
return OptionsManager()
elif isinstance(options_manager, OptionsManager):
return options_manager
else:
raise NotAFalyxError(
"options_manager 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_manager: 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_manager (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.")
bound_command = command.clone_with_overrides(
options_manager=options_manager,
program=program,
)
return cls(
command=bound_command,
program=program,
options_manager=options_manager,
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_manager: 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_manager (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_manager = options_manager 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_manager,
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_manager=options_manager,
runner_hooks=runner_hooks,
console=console,
)

View File

@@ -1,22 +1,37 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Provides `FalyxCompleter`, an intelligent autocompletion engine for Falyx CLI
menus using Prompt Toolkit.
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Prompt Toolkit completion support for routed Falyx command input.
This completer supports:
- Command key and alias completion (e.g. `R`, `HELP`, `X`)
- Argument flag completion for registered commands (e.g. `--tag`, `--name`)
- Context-aware suggestions based on cursor position and argument structure
- Interactive value completions (e.g. choices and suggestions defined per argument)
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.
Completions are sourced from `CommandArgumentParser.suggest_next`, which analyzes
parsed tokens to determine appropriate next arguments, flags, or values.
Completion behavior is split into two phases:
Integrated with the `Falyx.prompt_session` to enhance the interactive experience.
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
@@ -28,101 +43,239 @@ if TYPE_CHECKING:
class FalyxCompleter(Completer):
"""
Prompt Toolkit completer for Falyx CLI command input.
"""Prompt Toolkit completer for routed Falyx input.
This completer provides real-time, context-aware suggestions for:
- Command keys and aliases (resolved via Falyx._name_map)
- CLI argument flags and values for each command
- Suggestions and choices defined in the associated CommandArgumentParser
`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:
It leverages `CommandArgumentParser.suggest_next()` to compute valid completions
based on current argument state, including:
- Remaining required or optional flags
- Flag value suggestions (choices or custom completions)
- Next positional argument hints
- 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): The Falyx menu instance containing all command mappings and parsers.
falyx (Falyx): Active Falyx application instance used to resolve routes
and retrieve completion candidates.
"""
def __init__(self, falyx: "Falyx"):
self.falyx = falyx
def get_completions(self, document: Document, complete_event) -> Iterable[Completion]:
"""
Yield completions based on the current document input.
def __init__(self, falyx: Falyx):
"""Initialize the completer with a bound Falyx instance.
Args:
document (Document): The prompt_toolkit document containing the input buffer.
complete_event: The completion trigger event (unused).
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 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 objects matching command keys or argument suggestions.
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_of_token = document.text_before_cursor.endswith((" ", "\t"))
cursor_at_end = text.endswith((" ", "\t"))
except ValueError:
return
if not tokens or (len(tokens) == 1 and not cursor_at_end_of_token):
# Suggest command keys and aliases
yield from self._suggest_commands(tokens[0] if tokens else "")
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:
namespace_suggestions, expecting_value = route.namespace.parser.suggest_next(
route.remaining_argv, route.cursor_at_end_of_token
)
yield from self._yield_completions(namespace_suggestions, route.stub)
if expecting_value:
return
suggestions = self._suggest_namespace_entries(route.namespace, route.stub)
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
# Identify command
command_key = tokens[0].upper()
command = self.falyx._name_map.get(command_key)
if not command or not command.arg_parser:
# Leaf command: CAP owns the rest
if not route.command or not route.command.arg_parser:
return
# If at end of token, e.g., "--t" vs "--tag ", add a stub so suggest_next sees it
parsed_args = tokens[1:] if cursor_at_end_of_token else tokens[1:-1]
stub = "" if cursor_at_end_of_token else tokens[-1]
leaf_tokens = list(route.leaf_argv)
if route.stub:
leaf_tokens.append(route.stub)
try:
if not command.arg_parser:
return
suggestions = command.arg_parser.suggest_next(
parsed_args + ([stub] if stub else []), cursor_at_end_of_token
suggestions = route.command.arg_parser.suggest_next(
leaf_tokens,
route.cursor_at_end_of_token,
)
for suggestion in suggestions:
if suggestion.startswith(stub):
if len(suggestion.split()) > 1:
yield Completion(
f'"{suggestion}"',
start_position=-len(stub),
display=suggestion,
)
else:
yield Completion(suggestion, start_position=-len(stub))
except Exception:
return
def _suggest_commands(self, prefix: str) -> Iterable[Completion]:
"""
Suggest top-level command keys and aliases based on the given prefix.
yield from self._yield_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:
prefix (str): The user input to match against available commands.
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:
# results.append(name)
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_completions(
self,
suggestions: list[str],
stub: str,
) -> Iterable[Completion]:
"""Yield Completion objects for a list of suggestion strings.
This helper converts raw suggestion strings into Prompt Toolkit `Completion`
instances with appropriate insertion behavior. It assumes that the caller
has already determined the correct start position for insertion.
Args:
suggestions (list[str]): Raw completion candidates to convert.
stub (str): The currently typed prefix (used to offset insertion).
"""
for suggestion in suggestions:
yield Completion(
self._ensure_quote(suggestion),
start_position=-len(stub),
display=suggestion,
)
def _yield_lcp_completions(
self, suggestions: list[str], stub: str
) -> 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: Matching keys or aliases from all registered commands.
Completion: Completion objects for the Prompt Toolkit menu.
"""
prefix = prefix.upper()
keys = [self.falyx.exit_command.key]
keys.extend(self.falyx.exit_command.aliases)
if self.falyx.history_command:
keys.append(self.falyx.history_command.key)
keys.extend(self.falyx.history_command.aliases)
if self.falyx.help_command:
keys.append(self.falyx.help_command.key)
keys.extend(self.falyx.help_command.aliases)
for cmd in self.falyx.commands.values():
keys.append(cmd.key)
keys.extend(cmd.aliases)
for key in keys:
if key.upper().startswith(prefix):
yield Completion(key, start_position=-len(prefix))
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,
)

91
falyx/completer_types.py Normal file
View File

@@ -0,0 +1,91 @@
# 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.
remaining_argv (list[str]): Remaining argv tokens that have not yet been
consumed by routing or command resolution. These are typically passed
to the next routing or parsing stage for further resolution.
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)
remaining_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,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Configuration loader and schema definitions for the Falyx CLI framework.
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Configuration loader and schema definitions for the Falyx CLI framework.
This module supports config-driven initialization of CLI commands and submenus
from YAML or TOML files. It enables declarative command definitions, auto-imports

View File

@@ -1,7 +1,22 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
# 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 get_nord_theme
from falyx.exceptions import FalyxError
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:
if hint is None and isinstance(message, FalyxError):
hint = message.hint
error_console.print(f"[{OneColors.DARK_RED}]error:[/] {message}")
if hint:
error_console.print(f"[{OneColors.LIGHT_YELLOW}]hint:[/] {hint}")

View File

@@ -1,19 +1,22 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Execution context management for Falyx CLI actions.
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Context models for Falyx execution and invocation state.
This module defines `ExecutionContext` and `SharedContext`, which are responsible for
capturing per-action and cross-action metadata during CLI workflow execution. These
context objects provide structured introspection, result tracking, error recording,
and time-based performance metrics.
This module defines the core context objects used throughout Falyx to track both
runtime execution metadata and routed invocation-path state.
- `ExecutionContext`: Captures runtime information for a single action execution,
including arguments, results, exceptions, timing, and logging.
- `SharedContext`: Maintains shared state and result propagation across
`ChainedAction` or `ActionGroup` executions.
It provides:
- `ExecutionContext` for per-action execution details such as arguments,
results, exceptions, timing, and summary logging.
- `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,
supporting hook lifecycles, retries, and structured output generation.
Together, these models support Falyx lifecycle hooks, execution tracing,
history/introspection, and context-aware help and usage rendering across CLI
and menu modes.
"""
from __future__ import annotations
@@ -24,8 +27,12 @@ from typing import Any
from pydantic import BaseModel, ConfigDict, Field
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):
@@ -222,9 +229,9 @@ class SharedContext(BaseModel):
results (list[Any]): Captures results from each action, in order of execution.
errors (list[tuple[int, BaseException]]): Indexed list of errors from failed actions.
current_index (int): Index of the currently executing action (used in chains).
is_parallel (bool): Whether the context is used in parallel mode (ActionGroup).
is_concurrent (bool): Whether the context is used in concurrent mode (ActionGroup).
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
communication
between actions (e.g., flags, intermediate data, settings).
@@ -247,7 +254,7 @@ class SharedContext(BaseModel):
results: list[Any] = Field(default_factory=list)
errors: list[tuple[int, BaseException]] = Field(default_factory=list)
current_index: int = -1
is_parallel: bool = False
is_concurrent: bool = False
shared_result: Any | None = None
share: dict[str, Any] = Field(default_factory=dict)
@@ -262,11 +269,11 @@ class SharedContext(BaseModel):
def set_shared_result(self, result: Any) -> None:
self.shared_result = result
if self.is_parallel:
if self.is_concurrent:
self.results.append(result)
def last_result(self) -> Any:
if self.is_parallel:
if self.is_concurrent:
return self.shared_result
return self.results[-1] if self.results else None
@@ -277,14 +284,155 @@ class SharedContext(BaseModel):
self.share[key] = value
def __str__(self) -> str:
parallel_label = "Parallel" if self.is_parallel else "Sequential"
concurrent_label = "Concurrent" if self.is_concurrent else "Sequential"
return (
f"<{parallel_label}SharedContext '{self.name}' | "
f"<{concurrent_label}SharedContext '{self.name}' | "
f"Results: {self.results} | "
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__":
import asyncio

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Provides debug logging hooks for Falyx action execution.
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""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.

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,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines all custom exception classes used in the Falyx CLI framework.
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""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
@@ -18,7 +17,8 @@ Exception Hierarchy:
├── EmptyChainError
├── EmptyGroupError
├── EmptyPoolError
── CommandArgumentError
── CommandArgumentError
└── EntryNotFoundError
These are raised internally throughout the Falyx system to signal user-facing or
developer-facing problems that should be caught and reported.
@@ -26,11 +26,20 @@ developer-facing problems that should be caught and reported.
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):
"""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):
@@ -42,7 +51,7 @@ class InvalidActionError(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):
@@ -54,12 +63,185 @@ class EmptyChainError(FalyxError):
class EmptyGroupError(FalyxError):
"""Exception raised when the chain is empty."""
"""Exception raised when the group is empty."""
class EmptyPoolError(FalyxError):
"""Exception raised when the chain is empty."""
"""Exception raised when the pool is empty."""
class CommandArgumentError(FalyxError):
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."""
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 | str | None = None,
actual_count: int | None = None,
display_name: str | None = None,
show_short_usage: bool = True,
):
self.dest = dest
self.expected_count = expected_count
self.actual_count = actual_count
self.display_name = display_name or dest
super().__init__(
self.build_message(),
self.build_hint(),
show_short_usage=show_short_usage,
dest=dest,
)
def build_message(self) -> str:
if self.expected_count is None or self.expected_count in (1, "+"):
return f"missing value for '{self.display_name}'"
actual = 0 if self.actual_count is None else self.actual_count
return (
f"missing values for '{self.display_name}': "
f"expected {self.expected_count}, got {actual}"
)
def build_hint(self) -> str | None:
if self.expected_count is None or self.expected_count == 1:
return f"provide a value for '{self.display_name}'."
elif self.expected_count == "+":
return f"provide one or more values for '{self.display_name}'."
return f"provide {self.expected_count} values for '{self.display_name}'."
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,7 +1,6 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Provides the `ExecutionRegistry`, a centralized runtime store for capturing and inspecting
the execution history of Falyx actions.
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Provides the `ExecutionRegistry`, a centralized runtime store for capturing and
inspecting the execution history of Falyx actions.
The registry automatically records every `ExecutionContext` created during action
execution—including context metadata, results, exceptions, duration, and tracebacks.
@@ -63,8 +62,7 @@ from falyx.themes import OneColors
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` created by Falyx Actions,
tracking metadata, results, exceptions, and performance metrics. It enables
@@ -96,8 +94,7 @@ class ExecutionRegistry:
@classmethod
def record(cls, context: ExecutionContext):
"""
Record an execution context and assign a unique index.
"""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.
@@ -115,8 +112,7 @@ class ExecutionRegistry:
@classmethod
def get_all(cls) -> list[ExecutionContext]:
"""
Return all recorded execution contexts in order of execution.
"""Return all recorded execution contexts in order of execution.
Returns:
list[ExecutionContext]: All stored action contexts.
@@ -125,8 +121,7 @@ class ExecutionRegistry:
@classmethod
def get_by_name(cls, name: str) -> list[ExecutionContext]:
"""
Retrieve all executions recorded under a given action name.
"""Return all executions recorded under a given action name.
Args:
name (str): The name of the action.
@@ -138,8 +133,7 @@ class ExecutionRegistry:
@classmethod
def get_latest(cls) -> ExecutionContext:
"""
Return the most recent execution context.
"""Return the most recent execution context.
Returns:
ExecutionContext: The last recorded context.
@@ -148,8 +142,7 @@ class ExecutionRegistry:
@classmethod
def clear(cls):
"""
Clear all stored execution data and reset internal indices.
"""Clear all stored execution data and reset internal indices.
This operation is destructive and cannot be undone.
"""
@@ -167,8 +160,7 @@ class ExecutionRegistry:
last_result: bool = False,
status: Literal["all", "success", "error"] = "all",
):
"""
Display a formatted Rich table of recorded executions.
"""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.
@@ -264,7 +256,7 @@ class ExecutionRegistry:
if ctx.exception and status.lower() in ["all", "error"]:
final_status = f"[{OneColors.DARK_RED}]❌ Error"
final_result = repr(ctx.exception)
elif status.lower() in ["all", "success"]:
elif not ctx.exception and status.lower() in ["all", "success"]:
final_status = f"[{OneColors.GREEN}]✅ Success"
final_result = repr(ctx.result)
if len(final_result) > 50:

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines the `HookManager` and `HookType` used in the Falyx CLI framework to manage
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""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
@@ -31,8 +30,7 @@ Hook = Union[
class HookType(Enum):
"""
Enum for supported hook lifecycle phases in Falyx.
"""Enum for supported hook lifecycle phases in Falyx.
HookType is used to classify lifecycle events that can be intercepted
with user-defined callbacks.
@@ -91,8 +89,7 @@ class HookType(Enum):
class HookManager:
"""
Manages lifecycle hooks for a command or action.
"""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
@@ -114,8 +111,7 @@ class HookManager:
}
def register(self, hook_type: HookType | str, hook: Hook):
"""
Register a new hook for a given lifecycle phase.
"""Register a new hook for a given lifecycle phase.
Args:
hook_type (HookType | str): The hook category (e.g. "before", "on_success").
@@ -128,8 +124,7 @@ class HookManager:
self._hooks[hook_type].append(hook)
def clear(self, hook_type: HookType | None = None):
"""
Clear registered hooks for one or all hook types.
"""Clear registered hooks for one or all hook types.
Args:
hook_type (HookType | None): If None, clears all hooks.
@@ -141,8 +136,7 @@ class HookManager:
self._hooks[ht] = []
async def trigger(self, hook_type: HookType, context: ExecutionContext):
"""
Invoke all hooks registered for a given lifecycle phase.
"""Invoke all hooks registered for a given lifecycle phase.
Args:
hook_type (HookType): The lifecycle phase to trigger.
@@ -185,3 +179,10 @@ class HookManager:
hook_list = self._hooks.get(hook_type, [])
lines.append(f" {hook_type.value}: {format_hook_list(hook_list)}")
return "\n".join(lines)
def copy(self) -> HookManager:
"""Create a deep copy of this HookManager, including all registered hooks."""
new_manager = HookManager()
for hook_type, hooks in self._hooks.items():
new_manager._hooks[hook_type] = list(hooks)
return new_manager

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines reusable lifecycle hooks for Falyx Actions and Commands.
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Defines reusable lifecycle hooks for Falyx Actions and Commands.
This module includes:
- `spinner_before_hook`: Automatically starts a spinner before an action runs.
@@ -38,34 +37,34 @@ from falyx.themes import OneColors
async def spinner_before_hook(context: ExecutionContext):
"""Adds a spinner before the action starts."""
cmd = context.action
if cmd.options_manager is None:
command = context.action
if command.options_manager is None:
return
sm = context.action.options_manager.spinners
if hasattr(cmd, "name"):
cmd_name = cmd.name
if hasattr(command, "name"):
command_name = command.name
else:
cmd_name = cmd.key
command_name = command.key
await sm.add(
cmd_name,
cmd.spinner_message,
cmd.spinner_type,
cmd.spinner_style,
cmd.spinner_speed,
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)."""
cmd = context.action
if cmd.options_manager is None:
command = context.action
if command.options_manager is None:
return
if hasattr(cmd, "name"):
cmd_name = cmd.name
if hasattr(command, "name"):
command_name = command.name
else:
cmd_name = cmd.key
command_name = command.key
sm = context.action.options_manager.spinners
await sm.remove(cmd_name)
await sm.remove(command_name)
class ResultReporter:

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Project and global initializer for Falyx CLI environments.
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""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`.

View File

@@ -1,4 +1,4 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Global logger instance for Falyx CLI applications."""
import logging

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `MenuOption` and `MenuOptionMap`, core components used to construct
# 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,
@@ -69,6 +68,14 @@ class MenuOption:
[(OneColors.WHITE, f"[{key}] "), (self.style, self.description)]
)
def copy(self) -> MenuOption:
"""Create a copy of this MenuOption."""
return MenuOption(
description=self.description,
action=self.action.clone(),
style=self.style,
)
class MenuOptionMap(CaseInsensitiveDict):
"""
@@ -101,12 +108,16 @@ class MenuOptionMap(CaseInsensitiveDict):
self,
options: dict[str, MenuOption] | None = None,
allow_reserved: bool = False,
disable_reserved: bool = False,
):
super().__init__()
self.allow_reserved = allow_reserved
if options:
self.update(options)
self._inject_reserved_defaults()
if not disable_reserved:
self._inject_reserved_defaults()
else:
self.allow_reserved = True
def _inject_reserved_defaults(self):
from falyx.action import SignalAction
@@ -157,3 +168,13 @@ class MenuOptionMap(CaseInsensitiveDict):
if not include_reserved and key in self.RESERVED_KEYS:
continue
yield key, option
def copy(self) -> MenuOptionMap:
"""Create a copy of this MenuOptionMap."""
items = {}
for key, option in self.items():
if key in self.RESERVED_KEYS and not self.allow_reserved:
continue
items[key] = option.copy()
new_map = MenuOptionMap(items, allow_reserved=self.allow_reserved)
return new_map

View File

@@ -1,12 +1,42 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `FalyxMode`, an enum representing the different modes of operation for Falyx.
# 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"
RUN = "run"
COMMAND = "command"
PREVIEW = "preview"
RUN_ALL = "run-all"
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,80 +1,174 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Manages global or scoped CLI options across namespaces for Falyx commands.
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Option state management for Falyx CLI runtimes.
The `OptionsManager` provides a centralized interface for retrieving, setting, toggling,
and introspecting options defined in `argparse.Namespace` objects. It is used internally
by Falyx to pass and resolve runtime flags like `--verbose`, `--force-confirm`, etc.
This module defines `OptionsManager`, a small utility responsible for
storing, retrieving, and temporarily overriding runtime option values across
named namespaces.
Each option is stored under a namespace key (e.g., "cli_args", "user_config") to
support multiple sources of configuration.
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.
Key Features:
- Safe getter/setter for typed option resolution
- Toggle support for boolean options (used by bottom bar toggles, etc.)
- Callable getter/toggler wrappers for dynamic UI bindings
- Namespace merging via `from_namespace`
In addition to basic get/set operations, the manager provides helpers for:
Typical Usage:
- 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_namespace(args, namespace_name="cli_args")
options.from_mapping({"verbose": True})
if options.get("verbose"):
...
options.toggle("force_confirm")
value_fn = options.get_value_getter("dry_run")
toggle_fn = options.get_toggle_function("debug")
Used by:
- Falyx CLI runtime configuration
- Bottom bar toggles
- Dynamic flag injection into commands and actions
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 argparse import Namespace
from collections import defaultdict
from typing import Any, Callable
from contextlib import contextmanager
from copy import deepcopy
from typing import Any, Callable, Iterator, Mapping
from falyx.logger import logger
from falyx.spinner_manager import SpinnerManager
class OptionsManager:
"""
Manages CLI option state across multiple argparse namespaces.
"""Manage mutable option values across named runtime namespaces.
Allows dynamic retrieval, setting, toggling, and introspection of command-line
options. Supports named namespaces (e.g., "cli_args") and is used throughout
Falyx for runtime configuration and bottom bar toggle integration.
`OptionsManager` is the central store for Falyx runtime flags. Each option
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, Namespace]] | None = None) -> None:
self.options: defaultdict = defaultdict(Namespace)
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:
for namespace_name, namespace in namespaces:
self.from_namespace(namespace, namespace_name)
self.from_mapping(namespace, namespace_name)
def from_namespace(
self, namespace: Namespace, namespace_name: str = "cli_args"
def from_mapping(
self,
values: Mapping[str, Any],
namespace_name: str = "default",
) -> 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(
self, option_name: str, default: Any = None, namespace_name: str = "cli_args"
self,
option_name: str,
default: Any = None,
namespace_name: str = "default",
) -> Any:
"""Get the value of an option."""
return getattr(self.options[namespace_name], option_name, default)
"""Return an option value from a namespace.
def set(self, option_name: str, value: Any, namespace_name: str = "cli_args") -> None:
"""Set the value of an option."""
setattr(self.options[namespace_name], option_name, value)
Args:
option_name (str): Name of the option to retrieve.
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:
"""Check if an option exists in the namespace."""
return hasattr(self.options[namespace_name], option_name)
Returns:
Any: The stored option value if present, otherwise `default`.
"""
return self.options[namespace_name].get(option_name, default)
def toggle(self, option_name: str, namespace_name: str = "cli_args") -> None:
"""Toggle a boolean option."""
def set(
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)
if not isinstance(current, bool):
raise TypeError(
@@ -86,9 +180,24 @@ class OptionsManager:
)
def get_value_getter(
self, option_name: str, namespace_name: str = "cli_args"
self,
option_name: str,
namespace_name: str = "default",
) -> 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:
return self.get(option_name, namespace_name=namespace_name)
@@ -96,17 +205,108 @@ class OptionsManager:
return _getter
def get_toggle_function(
self, option_name: str, namespace_name: str = "cli_args"
self,
option_name: str,
namespace_name: str = "default",
) -> 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:
self.toggle(option_name, namespace_name=namespace_name)
return _toggle
def get_namespace_dict(self, namespace_name: str) -> Namespace:
"""Return all options in a namespace as a dictionary."""
def get_namespace(self, namespace_name: str) -> dict[str, Any] | None:
"""Return the option dictionary for a namespace.
Args:
namespace_name (str): Name of the namespace to retrieve.
Returns:
dict[str, Any]: The options stored in the requested namespace.
"""
if namespace_name not in self.options:
raise ValueError(f"Namespace '{namespace_name}' not found.")
return vars(self.options[namespace_name])
return None
return self.options[namespace_name]
def get_namespace_copy(self, namespace_name: str) -> dict[str, Any] | None:
"""Return a deep 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.
"""
if namespace_name not in self.options:
return None
return deepcopy(self.options[namespace_name])
def seed_missing(
self,
defaults: Mapping[str, Any],
namespace_name: str = "default",
) -> None:
"""Seed missing options in a namespace from a defaults mapping.
This method only sets options that are not already present in the target
namespace, allowing it to be used for layering default values without
overwriting existing settings.
Args:
defaults (Mapping[str, Any]): Default option values to seed.
namespace_name (str): Namespace to update. Defaults to `"default"`.
"""
for key, value in defaults.items():
if key not in self.options[namespace_name]:
self.options[namespace_name][key] = value
@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_copy(namespace_name)
if original is None:
raise ValueError(
f"Cannot override non-existent namespace '{namespace_name}'."
)
try:
self.from_mapping(values=overrides, namespace_name=namespace_name)
yield
finally:
self.options[namespace_name] = original
def __str__(self) -> str:
return f"OptionsManager(namespaces={list(self.options.keys())})"

View File

@@ -1,21 +1,20 @@
"""
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.
"""
from .argument import Argument
from .argument_action import ArgumentAction
from .command_argument_parser import CommandArgumentParser
from .parsers import FalyxParsers, get_arg_parsers, get_root_parser, get_subparsers
from .falyx_parser import FalyxParser, OptionAction
from .parse_result import ParseResult
__all__ = [
"Argument",
"ArgumentAction",
"CommandArgumentParser",
"get_arg_parsers",
"get_root_parser",
"get_subparsers",
"FalyxParsers",
"FalyxParser",
"OptionAction",
"ParseResult",
]

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines the `Argument` dataclass used by `CommandArgumentParser` to represent
# 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,
@@ -33,6 +32,8 @@ Used By:
- Rich-based CLI help generation
- Completion and preview suggestions
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@@ -42,8 +43,7 @@ from falyx.parser.argument_action import ArgumentAction
@dataclass
class Argument:
"""
Represents a command-line argument.
"""Represents a command-line argument.
Attributes:
flags (tuple[str, ...]): Short and long flags for the argument.
@@ -60,6 +60,8 @@ class Argument:
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, ...]
@@ -75,6 +77,8 @@ class Argument:
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."""
@@ -132,6 +136,8 @@ class Argument:
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:
@@ -147,5 +153,27 @@ class Argument:
self.positional,
self.default,
self.help,
self.group,
self.mutex_group,
)
)
def copy(self) -> Argument:
"""Create a copy of this Argument."""
return Argument(
flags=self.flags,
dest=self.dest,
action=self.action,
type=self.type,
default=self.default,
choices=list(self.choices) if self.choices else [],
required=self.required,
help=self.help,
nargs=self.nargs,
positional=self.positional,
resolver=self.resolver,
lazy_resolver=self.lazy_resolver,
suggestions=list(self.suggestions) if self.suggestions else None,
group=self.group,
mutex_group=self.mutex_group,
)

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `ArgumentAction`, an enum used to standardize the behavior of CLI arguments
# 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
@@ -24,8 +23,7 @@ from enum import Enum
class ArgumentAction(Enum):
"""
Defines the action to be taken when the argument is encountered.
"""Defines the action to be taken when the argument is encountered.
This enum mirrors the core behavior of Python's `argparse` actions, with a few
Falyx-specific extensions. It is used when defining command-line arguments for

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,677 @@
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
from __future__ import annotations
import re
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from falyx.exceptions import EntryNotFoundError, FalyxOptionError
from falyx.mode import FalyxMode
from falyx.parser.option import Option, OptionScope
from falyx.parser.option_action import OptionAction
from falyx.parser.parse_result import ParseResult
from falyx.parser.parser_types import (
FalyxTLDRExample,
FalyxTLDRInput,
OptionState,
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 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.help_option: Option | None = None
self.tldr_option: Option | None = None
self._last_option_states: dict[str, OptionState] = {}
self._add_reserved_options()
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 option 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,
) -> Option:
"""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('-')}"
option = Option(
flags=flags,
dest=dest,
action=OptionAction.STORE_BOOL_OPTIONAL,
type=true_none,
default=None,
help=help,
)
negated_option = Option(
flags=(negated_flag,),
dest=dest,
action=OptionAction.STORE_BOOL_OPTIONAL,
type=false_none,
default=None,
help=help,
)
self._register_option(option)
self._register_option(negated_option, bypass_validation=True)
return option
def _register_option(self, option: Option, bypass_validation: bool = False) -> None:
self._dest_set.add(option.dest)
self._options.append(option)
self._last_option_states[option.dest] = OptionState(option)
for flag in option.flags:
if flag in self._options_by_dest and not bypass_validation:
existing = self._options_by_dest[flag]
raise FalyxOptionError(
f"flag '{flag}' is already used by option '{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 option '{existing.dest}'"
)
if not re.match(r"^[a-zA-Z0-9_-]+$", flag.lstrip("-")):
raise FalyxOptionError(
f"invalid flag '{flag}': must only contain letters, digits, underscores, or hyphens"
)
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 _normalize_default_type(
self,
default: Any,
expected_type: Any,
dest: str,
) -> Any:
if default is None:
return None
try:
return 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: Callable[[Any], Any],
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
normalized: list[Any] = []
for choice in choices:
try:
normalized.append(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 normalized
def add_option(
self,
*flags: str,
action: str | OptionAction = "store",
default: Any = None,
type: Callable[[Any], Any] = str,
choices: list[str] | None = None,
help: str = "",
dest: str | None = None,
suggestions: list[str] | None = None,
) -> Option:
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)
if action is OptionAction.STORE:
default = self._normalize_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:
return self._register_store_bool_optional(flags, dest, help)
option = Option(
flags=flags,
dest=dest,
action=action,
type=type,
default=default,
choices=choices,
help=help,
suggestions=suggestions,
)
self._register_option(option)
return option
def _filter_suggestions(
self,
suggestion: str,
prefix: str,
cursor_at_end_of_token: bool,
) -> bool:
if cursor_at_end_of_token:
return True
return suggestion.startswith(prefix)
def _value_suggestions_for_option(
self,
option: Option,
prefix: str,
cursor_at_end_of_token: bool,
) -> list[str]:
if option.choices:
return [
str(choice)
for choice in option.choices
if self._filter_suggestions(str(choice), prefix, cursor_at_end_of_token)
]
if option.suggestions:
return [
suggestion
for suggestion in option.suggestions
if self._filter_suggestions(suggestion, prefix, cursor_at_end_of_token)
]
return []
def suggest_next(
self,
args: list[str],
cursor_at_end_of_token: bool,
) -> tuple[list[str], bool]:
"""Suggest the next possible flags based on the current input stub."""
expecting_value = False
if not args:
return [], expecting_value
options = self._resolve_posix_bundling(args)
consumed_dests = [
state.option.dest
for state in self._last_option_states.values()
if state.consumed
]
remaining_flags = [
flag
for flag, option in self._options_by_dest.items()
if option.dest not in consumed_dests
]
last = options[-1] if options else ""
last_option_in_options = None
for option in reversed(options):
if option in self._options_by_dest:
last_option_in_options = self._options_by_dest[option]
break
suggestions: list[str] = []
if last.startswith("-") and last not in self._options_by_dest:
suggestions.extend(flag for flag in remaining_flags if flag.startswith(last))
elif (
last_option_in_options
and not self._last_option_states[last_option_in_options.dest].consumed
):
suggestions.extend(
self._value_suggestions_for_option(
last_option_in_options,
prefix=last,
cursor_at_end_of_token=cursor_at_end_of_token,
)
)
if last_option_in_options.action is OptionAction.STORE:
expecting_value = True
return suggestions, expecting_value
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 options into separate options."""
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],
option_states: dict[str, OptionState],
) -> int:
match option.action:
case OptionAction.STORE_TRUE:
values[option.dest] = True
option_states[option.dest].set_consumed()
return index + 1
case OptionAction.STORE_FALSE:
values[option.dest] = False
option_states[option.dest].set_consumed()
return index + 1
case OptionAction.STORE_BOOL_OPTIONAL:
values[option.dest] = option.type(True)
option_states[option.dest].set_consumed()
return index + 1
case OptionAction.COUNT:
values[option.dest] = int(values.get(option.dest) or 0) + 1
option_states[option.dest].set_consumed()
return index + 1
case OptionAction.HELP:
values[option.dest] = True
option_states[option.dest].set_consumed()
return index + 1
case OptionAction.TLDR:
values[option.dest] = True
option_states[option.dest].set_consumed()
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
option_states[option.dest].set_consumed()
return index + 2
raise FalyxOptionError(f"unsupported option action: {option.action}")
def parse_args(
self,
argv: list[str] | None = None,
) -> ParseResult:
option_states = {option.dest: OptionState(option) for option in self._options}
self._last_option_states = option_states
raw_argv = argv or []
arguments = self._resolve_posix_bundling(raw_argv)
root_options: dict[str, Any] = {}
namespace_options: dict[str, Any] = {}
index = 0
while index < len(arguments):
token = arguments[index]
# 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_options if option.scope == OptionScope.ROOT else namespace_options
)
index = self._consume_option(
option,
arguments,
index,
target_values,
option_states,
)
remaining_argv = arguments[index:]
help_requested = namespace_options.get("help", False) or namespace_options.get(
"tldr", False
)
namespace_defaults, root_defaults = self._default_values()
return ParseResult(
mode=FalyxMode.HELP if help_requested else FalyxMode.COMMAND,
raw_argv=raw_argv,
root_defaults=root_defaults,
root_options=root_options,
namespace_defaults=namespace_defaults,
namespace_options=namespace_options,
remaining_argv=remaining_argv,
help=namespace_options.get("help", False),
tldr=namespace_options.get("tldr", False),
current_head=remaining_argv[0] if remaining_argv else "",
)

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

@@ -0,0 +1,93 @@
# 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: set[str] = field(default_factory=set)
def copy(self) -> ArgumentGroup:
"""Create a copy of this ArgumentGroup."""
return ArgumentGroup(
name=self.name,
description=self.description,
dests=set(self.dests),
)
@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: set[str] = field(default_factory=set)
def copy(self) -> MutuallyExclusiveGroup:
"""Create a copy of this MutuallyExclusiveGroup."""
return MutuallyExclusiveGroup(
name=self.name,
required=self.required,
description=self.description,
dests=set(self.dests),
)

41
falyx/parser/option.py Normal file
View File

@@ -0,0 +1,41 @@
# 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 Any
from falyx.parser.option_action import OptionAction
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)

View File

@@ -0,0 +1,44 @@
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
from __future__ import annotations
from enum import Enum
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 option 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 option action."""
return self.value

View File

@@ -0,0 +1,65 @@
# 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.
root_defaults: Dictionary of parsed root-level options and their default values.
root_options: Dictionary of parsed root-level options that should be
applied at the root level for all namespaces.
namespace_defaults: Dictionary of parsed namespace-level options and their default values.
namespace_options: Dictionary of parsed namespace-level options and their values.
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)
root_defaults: dict[str, Any] = field(default_factory=dict)
root_options: dict[str, Any] = field(default_factory=dict)
namespace_defaults: dict[str, Any] = field(default_factory=dict)
namespace_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

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Type utilities and argument state models for Falyx's custom CLI argument parser.
# 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.
@@ -16,18 +15,45 @@ Contents:
These tools support richer expressiveness and user-friendly ergonomics in
Falyx's declarative command-line interfaces.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from typing import Any, TypeAlias
from falyx.parser.argument import Argument
from falyx.parser.option import Option
class StateMixin:
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
class ArgumentState:
class ArgumentState(StateMixin):
"""Tracks an argument and whether it has been consumed."""
arg: Argument
consumed: bool = False
consumed_position: int | None = None
has_invalid_choice: bool = False
@dataclass
class OptionState(StateMixin):
"""Tracks an option argument and its consumed state, including the dest name."""
option: Option
consumed: bool = False
consumed_position: int | None = None
has_invalid_choice: bool = False
@dataclass(frozen=True)
@@ -37,6 +63,36 @@ class TLDRExample:
usage: str
description: str
def copy(self) -> TLDRExample:
"""Create a copy of this TLDRExample."""
return TLDRExample(
usage=self.usage,
description=self.description,
)
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
def copy(self) -> FalyxTLDRExample:
"""Create a copy of this FalyxTLDRExample."""
return FalyxTLDRExample(
entry_key=self.entry_key,
usage=self.usage,
description=self.description,
)
FalyxTLDRInput: TypeAlias = FalyxTLDRExample | tuple[str, str, str]
def true_none(value: Any) -> bool | None:
"""Return True if value is not None, else None."""

View File

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

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Provides utilities for introspecting Python callables and extracting argument
"""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
@@ -20,12 +19,23 @@ 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.
"""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,

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Contains value coercion and signature comparison utilities for Falyx argument parsing.
"""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
@@ -13,6 +12,7 @@ Functions:
- same_argument_definitions: Check if multiple callables share the same argument structure.
"""
import types
from collections.abc import Callable
from datetime import datetime
from enum import EnumMeta
from typing import Any, Literal, Union, get_args, get_origin
@@ -24,9 +24,18 @@ 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.
"""Convert a string to a boolean.
Accepts various truthy and falsy representations such as 'true', 'yes', '0', 'off', etc.
@@ -47,8 +56,7 @@ def coerce_bool(value: str) -> bool:
def coerce_enum(value: Any, enum_type: EnumMeta) -> Any:
"""
Convert a raw value or string to an Enum instance.
"""Convert a raw value or string to an Enum instance.
Tries to resolve by name, value, or coerced base type.
@@ -80,15 +88,14 @@ def coerce_enum(value: Any, enum_type: EnumMeta) -> Any:
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.
def coerce_value(value: str, target_type: Callable[[Any], Any]) -> 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.
target_type (Callable[[Any], Any]): The desired type.
Returns:
Any: The coerced value.
@@ -133,8 +140,7 @@ 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.
"""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.

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Utilities for user interaction prompts in the Falyx CLI framework.
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""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.
@@ -9,6 +8,8 @@ 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.formatted_text import (
@@ -25,23 +26,62 @@ from falyx.themes import OneColors
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(
*,
confirm: bool,
options: OptionsManager,
namespace: str = "cli_args",
):
"""
Determine whether to prompt the user for confirmation based on command
and global options.
"""
never_prompt = options.get("never_prompt", False, namespace)
force_confirm = options.get("force_confirm", False, namespace)
skip_confirm = options.get("skip_confirm", False, namespace)
action_never_prompt: bool | None = None,
namespace: str = "root",
override_namespace: str = "execution",
) -> bool:
"""Determine whether to prompt the user for confirmation.
if never_prompt or skip_confirm:
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 secondary namespace to check for options (default: "root").
override_namespace (str): The primary namespace for overrides (default: "execution").
Returns:
bool: True if the user should be prompted, False if confirmation can be bypassed.
"""
if action_never_prompt is True:
return False
skip_confirm = options.get("skip_confirm", None, override_namespace)
if skip_confirm:
return False
never_prompt = options.get("never_prompt", None, override_namespace)
if never_prompt is None:
never_prompt = options.get("never_prompt", False, namespace)
if never_prompt:
return False
force_confirm = options.get("force_confirm", None, override_namespace)
if force_confirm is None:
force_confirm = options.get("force_confirm", False, namespace)
return confirm or force_confirm
@@ -62,9 +102,16 @@ async def confirm_async(
def rich_text_to_prompt_text(text: Text | str | StyleAndTextTuples) -> StyleAndTextTuples:
"""
Convert a Rich Text object to a list of (style, text) tuples
compatible with prompt_toolkit.
"""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):

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines structural protocols for advanced Falyx features.
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Defines structural protocols for advanced Falyx features.
These runtime-checkable `Protocol` classes specify the expected interfaces for:
- Factories that asynchronously return actions
@@ -29,4 +28,6 @@ class ActionFactoryProtocol(Protocol):
@runtime_checkable
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,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Implements retry logic for Falyx Actions using configurable retry policies.
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Implements retry logic for Falyx Actions using configurable retry policies.
This module defines:
- `RetryPolicy`: A configurable model controlling retry behavior (delay, backoff, jitter).
@@ -30,8 +29,7 @@ from falyx.logger import logger
class RetryPolicy(BaseModel):
"""
Defines a retry strategy for Falyx `Action` objects.
"""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.
@@ -60,23 +58,16 @@ class RetryPolicy(BaseModel):
enabled: bool = False
def enable_policy(self) -> None:
"""
Enable the retry policy.
:return: None
"""
"""Enable the retry policy."""
self.enabled = True
def is_active(self) -> bool:
"""
Check if the retry policy is active.
:return: True if the retry policy is active, False otherwise.
"""
"""Check if the retry policy is active."""
return self.max_retries > 0 and self.enabled
class RetryHandler:
"""
Executes retry logic for Falyx actions using a provided `RetryPolicy`.
"""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

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Utilities for enabling retry behavior across Falyx actions.
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Utilities for enabling retry behavior across Falyx actions.
This module provides a helper to recursively apply a `RetryPolicy` to an action and its
nested children (e.g. `ChainedAction`, `ActionGroup`), and register the appropriate

101
falyx/routing.py Normal file
View File

@@ -0,0 +1,101 @@
# 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, Any
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.
root_overrides: Root-level option overrides to apply for this route.
namespace_overrides: Namespace-level option overrides to apply for this route.
"""
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
root_defaults: dict[str, Any] = field(default_factory=dict)
root_overrides: dict[str, Any] = field(default_factory=dict)
namespace_defaults: dict[str, Any] = field(default_factory=dict)
namespace_overrides: dict[str, Any] = field(default_factory=dict)

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Provides interactive selection utilities for Falyx CLI actions.
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""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`.
@@ -12,6 +11,8 @@ It supports:
Used by `SelectionAction` and other prompt-driven workflows within Falyx.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable, KeysView, Sequence
@@ -21,7 +22,7 @@ from rich.markup import escape
from rich.table import Table
from falyx.console import console
from falyx.prompt_utils import rich_text_to_prompt_text
from falyx.prompt_utils import prompt_session_context, rich_text_to_prompt_text
from falyx.themes import OneColors
from falyx.utils import CaseInsensitiveDict, chunks
from falyx.validators import MultiIndexValidator, MultiKeyValidator
@@ -44,11 +45,17 @@ class SelectionOption:
key = escape(f"[{key}]")
return f"[{OneColors.WHITE}]{key}[/] [{self.style}]{self.description}[/]"
def copy(self) -> SelectionOption:
"""Create a copy of the SelectionOption."""
return SelectionOption(
description=self.description,
value=self.value,
style=self.style,
)
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()
@@ -100,6 +107,13 @@ class SelectionOptionMap(CaseInsensitiveDict):
continue
yield k, v
def copy(self) -> SelectionOptionMap:
"""Create a copy of the SelectionOptionMap."""
new_map = SelectionOptionMap(allow_reserved=self.allow_reserved)
for key, option in self.items():
new_map[key] = option.copy()
return new_map
def render_table_base(
title: str,
@@ -118,6 +132,7 @@ def render_table_base(
highlight: bool = True,
column_names: Sequence[str] | None = None,
) -> Table:
"""Render the base table for selection prompts."""
table = Table(
title=title,
caption=caption,
@@ -288,24 +303,38 @@ async def prompt_for_index(
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()
if show_table:
console.print(table, justify="center")
selection = await prompt_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,
number_selections_str = (
f"{number_selections} " if isinstance(number_selections, int) else ""
)
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:
@@ -332,14 +361,27 @@ async def prompt_for_selection(
if show_table:
console.print(table, justify="center")
selected = await prompt_session.prompt_async(
message=rich_text_to_prompt_text(prompt_message),
validator=MultiKeyValidator(
keys, number_selections, separator, allow_duplicates, cancel_key
),
default=default_selection,
number_selections_str = (
f"{number_selections} " if isinstance(number_selections, int) else ""
)
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:

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines flow control signals used internally by the Falyx CLI framework.
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""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

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Centralized spinner rendering for Falyx CLI.
# 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.
@@ -55,8 +54,7 @@ from falyx.themes import OneColors
class SpinnerData:
"""
Holds the configuration and Rich spinner object for a single task.
"""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
@@ -92,8 +90,7 @@ class SpinnerData:
class SpinnerManager:
"""
Manages multiple Rich spinners and handles their terminal rendering.
"""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,

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Generates a Rich table view of Falyx commands grouped by their tags.
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""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
@@ -25,19 +24,19 @@ def build_tagged_table(flx: Falyx) -> Table:
# Group commands by first tag
grouped: dict[str, list[Command]] = defaultdict(list)
for cmd in flx.commands.values():
first_tag = cmd.tags[0] if cmd.tags else "Other"
grouped[first_tag.capitalize()].append(cmd)
for command in flx.commands.values():
first_tag = command.tags[0] if command.tags else "Other"
grouped[first_tag.capitalize()].append(command)
# Add grouped commands to table
for group_name, commands in grouped.items():
table.add_row(f"[bold underline]{group_name} Commands[/]")
for cmd in commands:
table.add_row(f"[{cmd.key}] [{cmd.style}]{cmd.description}")
for command in commands:
table.add_row(f"[{command.key}] [{command.style}]{command.description}")
table.add_row("")
# Add bottom row
for row in flx.get_bottom_row():
for row in flx._get_bottom_row():
table.add_row(row)
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.
"""

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
A Python module that integrates the Nord color palette with the Rich library.
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""A Python module that integrates the Nord color palette with the Rich library.
It defines a metaclass-based NordColors class allowing dynamic attribute lookups
(e.g., NORD12bu -> "#D08770 bold underline") and provides a comprehensive Nord-based
Theme that customizes Rich's default styles.
@@ -26,8 +25,7 @@ from rich.theme import Theme
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.
The color values are required to be uppercase with optional underscores and digits,
@@ -152,8 +150,7 @@ class OneColors(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)
and also has useful aliases grouped by theme:
@@ -212,8 +209,7 @@ class NordColors(metaclass=ColorsMeta):
@classmethod
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.
"""
return {
@@ -224,8 +220,7 @@ class NordColors(metaclass=ColorsMeta):
@classmethod
def aliases(cls):
"""
Returns a dictionary of *all* other aliases
"""Returns a dictionary of *all* other aliases
(Polar Night, Snow Storm, Frost, Aurora).
"""
skip_prefixes = ("NORD", "__")
@@ -462,9 +457,7 @@ NORD_THEME_STYLES: dict[str, Style] = {
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)

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
General-purpose utilities and helpers for the Falyx CLI framework.
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""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.
@@ -92,6 +91,9 @@ class CaseInsensitiveDict(dict):
def __getitem__(self, key):
return super().__getitem__(self._normalize_key(key))
def __delitem__(self, key):
super().__delitem__(self._normalize_key(key))
def __contains__(self, key):
return super().__contains__(self._normalize_key(key))
@@ -130,8 +132,7 @@ def setup_logging(
file_log_level: int = logging.DEBUG,
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.
This function sets up separate logging handlers for console and file output,

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Input validators for use with Prompt Toolkit and interactive Falyx CLI workflows.
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Input validators for use with Prompt Toolkit and interactive Falyx CLI workflows.
This module defines reusable `Validator` instances and subclasses that enforce valid
user input during prompts—especially for selection actions, confirmations, and
@@ -22,6 +21,8 @@ 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
@@ -48,10 +49,33 @@ class CommandValidator(Validator):
message=self.error_message,
cursor_position=len(text),
)
is_preview, choice, _, __ = await self.falyx.get_command(text, from_validate=True)
if is_preview:
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 not choice:
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),
@@ -118,6 +142,10 @@ def words_validator(
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":
@@ -128,6 +156,8 @@ def word_validator(word: str) -> Validator:
class MultiIndexValidator(Validator):
"""Validator for multiple index selections (e.g. '1,2,3')."""
def __init__(
self,
minimum: int,
@@ -178,6 +208,8 @@ class MultiIndexValidator(Validator):
class MultiKeyValidator(Validator):
"""Validator for multiple key selections (e.g. 'A,B,C')."""
def __init__(
self,
keys: Sequence[str] | KeysView[str],

View File

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

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "falyx"
version = "0.1.78"
version = "0.2.0"
description = "Reliable and introspectable async CLI action framework."
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
license = "MIT"

View File

@@ -0,0 +1,334 @@
import pytest
from falyx.action.action import Action
from falyx.action.action_group import ActionGroup
from falyx.action.chained_action import ChainedAction
from falyx.action.http_action import HTTPAction
from falyx.action.menu_action import MenuAction
from falyx.action.process_action import ProcessAction
from falyx.hook_manager import HookType
from falyx.menu import MenuOption, MenuOptionMap
from falyx.retry import RetryHandler, RetryPolicy
def _retry_hooks(action) -> list:
return [
hook
for hook in action.hooks._hooks[HookType.ON_ERROR]
if isinstance(getattr(hook, "__self__", None), RetryHandler)
]
def _non_retry_error_hooks(action) -> list:
return [
hook
for hook in action.hooks._hooks[HookType.ON_ERROR]
if not isinstance(getattr(hook, "__self__", None), RetryHandler)
]
def _before_hooks(action) -> list:
return list(action.hooks._hooks[HookType.BEFORE])
def test_action_group_clone_recursively_isolates_nested_action_graph():
nested_chain = ChainedAction(
name="nested-chain",
actions=[
Action("step-two", lambda: "two"),
Action("step-three", lambda: "three"),
],
)
original = ActionGroup(
name="group",
actions=[
Action("step-one", lambda: "one"),
nested_chain,
],
)
cloned = original.clone()
assert cloned is not original
assert cloned.actions is not original.actions
assert len(cloned.actions) == len(original.actions)
# Top-level children are cloned.
assert cloned.actions[0] is not original.actions[0]
assert cloned.actions[1] is not original.actions[1]
# Nested action graph is also cloned.
assert isinstance(cloned.actions[1], ChainedAction)
assert cloned.actions[1].actions is not original.actions[1].actions
for cloned_child, original_child in zip(
cloned.actions[1].actions,
original.actions[1].actions,
strict=True,
):
assert cloned_child is not original_child
assert cloned_child.name == original_child.name
# Mutating the clone does not mutate the original.
cloned.actions.append(Action("step-four", lambda: "four"))
assert len(cloned.actions) == 3
assert len(original.actions) == 2
cloned.actions[1].actions.append(Action("step-five", lambda: "five"))
assert len(cloned.actions[1].actions) == 3
assert len(original.actions[1].actions) == 2
def test_menu_action_clone_copies_menu_option_map_and_clones_contained_actions():
menu_options = MenuOptionMap(disable_reserved=True)
menu_options["A"] = MenuOption(
description="Alpha",
action=Action("alpha-action", lambda: "alpha"),
)
original = MenuAction(
name="main-menu",
menu_options=menu_options,
title="Main Menu",
)
cloned = original.clone()
assert cloned is not original
assert cloned.menu_options is not original.menu_options
assert cloned.menu_options["A"] is not original.menu_options["A"]
assert cloned.menu_options["A"].description == original.menu_options["A"].description
# Contained action should also be cloned.
assert cloned.menu_options["A"].action is not original.menu_options["A"].action
assert cloned.menu_options["A"].action.name == original.menu_options["A"].action.name
# Mutating the clone should not affect the original.
cloned.menu_options["A"].description = "Changed"
assert original.menu_options["A"].description == "Alpha"
cloned.menu_options["B"] = MenuOption(
description="Beta",
action=Action("beta-action", lambda: "beta"),
)
assert "B" in cloned.menu_options
assert "B" not in original.menu_options
def test_process_action_clone_does_not_reuse_runtime_only_executor_state():
original = ProcessAction(
name="proc",
action=lambda x: x + 1,
args=(1,),
kwargs={"y": 2},
)
original.executor = object()
cloned = original.clone()
assert cloned is not original
assert cloned.hooks is not original.hooks
assert cloned.args == original.args
assert cloned.kwargs == original.kwargs
assert cloned.executor is not original.executor
def test_http_action_clone_preserves_retry_policy_without_duplicating_spinner_hooks():
retry_policy = RetryPolicy(max_retries=3, delay=1.5, backoff=2.0)
retry_policy.enable_policy()
original = HTTPAction(
name="get-users",
method="GET",
url="https://example.com/api/users",
headers={"Authorization": "Bearer token"},
params={"page": 1},
retry_policy=retry_policy,
spinner=True,
)
before_count = len(original.hooks._hooks[HookType.BEFORE])
teardown_count = len(original.hooks._hooks[HookType.ON_TEARDOWN])
error_count = len(original.hooks._hooks[HookType.ON_ERROR])
cloned = original.clone()
assert cloned is not original
assert cloned.hooks is not original.hooks
assert cloned.retry_policy is not original.retry_policy
assert cloned.retry_policy.enabled is original.retry_policy.enabled
assert cloned.retry_policy.max_retries == original.retry_policy.max_retries
assert cloned.retry_policy.delay == original.retry_policy.delay
assert cloned.retry_policy.backoff == original.retry_policy.backoff
assert len(cloned.hooks._hooks[HookType.BEFORE]) == before_count
assert len(cloned.hooks._hooks[HookType.ON_TEARDOWN]) == teardown_count
assert len(cloned.hooks._hooks[HookType.ON_ERROR]) == error_count
@pytest.mark.asyncio
async def test_action_clone_registers_exactly_one_retry_hook():
async def flaky():
return "ok"
policy = RetryPolicy(max_retries=2, delay=0.1, backoff=2.0)
policy.enable_policy()
original = Action(
"flaky",
flaky,
retry_policy=policy,
)
cloned = original.clone()
original_retry_hooks = _retry_hooks(original)
cloned_retry_hooks = _retry_hooks(cloned)
assert len(original_retry_hooks) == 1
assert len(cloned_retry_hooks) == 1
assert cloned_retry_hooks[0] is not original_retry_hooks[0]
assert getattr(cloned_retry_hooks[0], "__self__", None) is not getattr(
original_retry_hooks[0], "__self__", None
)
def test_action_clone_preserves_non_retry_hooks_without_duplication():
calls = []
async def custom_error_hook(context):
calls.append(context.name)
original = Action("demo", lambda: "ok")
original.hooks.register(HookType.BEFORE, lambda context: None)
original.hooks.register(HookType.ON_ERROR, custom_error_hook)
cloned = original.clone()
assert len(_before_hooks(cloned)) == len(_before_hooks(original))
assert len(_non_retry_error_hooks(cloned)) == len(_non_retry_error_hooks(original))
assert cloned.hooks is not original.hooks
def test_action_clone_copies_retry_policy_without_sharing_it():
policy = RetryPolicy(max_retries=2, delay=0.25, backoff=3.0)
policy.enable_policy()
original = Action(
"demo",
lambda: "ok",
retry_policy=policy,
)
cloned = original.clone()
assert cloned.retry_policy is not original.retry_policy
assert cloned.retry_policy.enabled is original.retry_policy.enabled
assert cloned.retry_policy.max_retries == original.retry_policy.max_retries
assert cloned.retry_policy.delay == original.retry_policy.delay
assert cloned.retry_policy.backoff == original.retry_policy.backoff
cloned.retry_policy.max_retries = 9
assert original.retry_policy.max_retries == 2
@pytest.mark.asyncio
async def test_action_clone_retry_behavior_still_works_independently():
state = {"original": 0, "clone": 0}
async def flaky_original():
if state["original"] == 0:
state["original"] += 1
raise RuntimeError("boom")
return "original-ok"
async def flaky_clone():
if state["clone"] == 0:
state["clone"] += 1
raise RuntimeError("boom")
return "clone-ok"
policy = RetryPolicy(max_retries=1, delay=0.0, backoff=1.0)
policy.enable_policy()
original = Action("orig", flaky_original, retry_policy=policy)
cloned = original.clone()
cloned.action = flaky_clone
original_result = await original()
cloned_result = await cloned()
assert original_result == "original-ok"
assert cloned_result == "clone-ok"
assert state["original"] == 1
assert state["clone"] == 1
def test_http_action_clone_registers_exactly_one_retry_hook():
policy = RetryPolicy(max_retries=2, delay=0.1, backoff=2.0)
policy.enable_policy()
original = HTTPAction(
name="get-users",
method="GET",
url="https://example.com/api/users",
retry_policy=policy,
spinner=True,
)
cloned = original.clone()
original_retry_hooks = _retry_hooks(original)
cloned_retry_hooks = _retry_hooks(cloned)
assert len(original_retry_hooks) == 1
assert len(cloned_retry_hooks) == 1
assert cloned_retry_hooks[0] is not original_retry_hooks[0]
def test_http_action_clone_copies_retry_policy_without_sharing_it():
policy = RetryPolicy(max_retries=3, delay=1.5, backoff=2.0)
policy.enable_policy()
original = HTTPAction(
name="get-users",
method="GET",
url="https://example.com/api/users",
retry_policy=policy,
)
cloned = original.clone()
assert cloned.retry_policy is not original.retry_policy
assert cloned.retry_policy.enabled is original.retry_policy.enabled
assert cloned.retry_policy.max_retries == original.retry_policy.max_retries
assert cloned.retry_policy.delay == original.retry_policy.delay
assert cloned.retry_policy.backoff == original.retry_policy.backoff
def test_http_action_clone_does_not_duplicate_spinner_hooks():
policy = RetryPolicy(max_retries=1, delay=0.0, backoff=1.0)
policy.enable_policy()
original = HTTPAction(
name="get-users",
method="GET",
url="https://example.com/api/users",
retry_policy=policy,
spinner=True,
)
cloned = original.clone()
assert len(cloned.hooks._hooks[HookType.BEFORE]) == len(
original.hooks._hooks[HookType.BEFORE]
)
assert len(cloned.hooks._hooks[HookType.ON_TEARDOWN]) == len(
original.hooks._hooks[HookType.ON_TEARDOWN]
)

View File

@@ -0,0 +1,100 @@
import pytest
from rich.text import Text
from falyx.action import LoadFileAction
from falyx.console import console as falyx_console
@pytest.mark.asyncio
async def test_load_json_file_action(tmp_path):
mock_data = '{"key": "value"}'
file = tmp_path / "test.json"
file.write_text(mock_data)
action = LoadFileAction(name="load-file", file_path=file, file_type="json")
result = await action()
assert result == {"key": "value"}
@pytest.mark.asyncio
async def test_load_yaml_file_action(tmp_path):
mock_data = "key: value"
file = tmp_path / "test.yaml"
file.write_text(mock_data)
action = LoadFileAction(name="load-file", file_path=file, file_type="yaml")
result = await action()
assert result == {"key": "value"}
@pytest.mark.asyncio
async def test_load_toml_file_action(tmp_path):
mock_data = 'key = "value"'
file = tmp_path / "test.toml"
file.write_text(mock_data)
action = LoadFileAction(name="load-file", file_path=file, file_type="toml")
result = await action()
assert result == {"key": "value"}
@pytest.mark.asyncio
async def test_load_csv_file_action(tmp_path):
mock_data = "key,value\nfoo,bar"
file = tmp_path / "test.csv"
file.write_text(mock_data)
action = LoadFileAction(name="load-file", file_path=file, file_type="csv")
result = await action()
print(result)
assert result == [["key", "value"], ["foo", "bar"]]
@pytest.mark.asyncio
async def test_load_tsv_file_action(tmp_path):
mock_data = "key\tvalue\nfoo\tbar"
file = tmp_path / "test.tsv"
file.write_text(mock_data)
action = LoadFileAction(name="load-file", file_path=file, file_type="tsv")
result = await action()
assert result == [["key", "value"], ["foo", "bar"]]
@pytest.mark.asyncio
async def test_load_file_action_invalid_path():
action = LoadFileAction(
name="load-file", file_path="non_existent_file.json", file_type="json"
)
with pytest.raises(FileNotFoundError):
await action()
@pytest.mark.asyncio
async def test_load_file_action_invalid_json(tmp_path):
invalid_json = '{"key": "value"' # Missing closing brace
file = tmp_path / "invalid.json"
file.write_text(invalid_json)
action = LoadFileAction(name="load-file", file_path=file, file_type="json")
with pytest.raises(ValueError):
await action()
@pytest.mark.asyncio
async def test_load_file_action_unsupported_type(tmp_path):
file = tmp_path / "test.txt"
file.write_text("Just some text")
with pytest.raises(ValueError):
LoadFileAction(name="load-file", file_path=file, file_type="unsupported")
@pytest.mark.asyncio
async def test_preview_of_load_file_action(tmp_path):
mock_data = '{"key": "value"}'
file = tmp_path / "test.json"
file.write_text(mock_data)
action = LoadFileAction(name="load-file", file_path=file, file_type="json")
with falyx_console.capture() as capture:
await action.preview()
captured = Text.from_ansi(capture.get()).plain
assert "LoadFileAction" in captured
assert "test.json" in captured
assert "load-file" in captured
assert "JSON" in captured
assert "key" in captured
assert "value" in captured

View File

@@ -0,0 +1,430 @@
from __future__ import annotations
import csv
import json
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any
import pytest
import toml
import yaml
from rich.tree import Tree
from falyx.action.action_types import FileType
from falyx.action.save_file_action import SaveFileAction
from falyx.hook_manager import HookType
class CaptureConsole:
def __init__(self) -> None:
self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
def print(self, *args: Any, **kwargs: Any) -> None:
self.printed.append((args, kwargs))
def make_action(file_path: Path | str | None, **overrides: Any) -> SaveFileAction:
defaults: dict[str, Any] = {
"name": "SaveOutput",
"file_path": file_path,
}
defaults.update(overrides)
return SaveFileAction(**defaults)
def register_lifecycle_hooks(action: SaveFileAction) -> list[tuple[HookType, Any]]:
calls: list[tuple[HookType, Any]] = []
def make_hook(hook_type: HookType):
def hook(context: Any) -> None:
calls.append((hook_type, context))
return hook
for hook_type in HookType:
action.hooks.register(hook_type, make_hook(hook_type))
return calls
def hook_types(calls: list[tuple[HookType, Any]]) -> list[HookType]:
return [hook_type for hook_type, _ in calls]
def test_init_normalizes_configuration_and_string_file_type(tmp_path: Path) -> None:
target = tmp_path / "output.json"
action = SaveFileAction(
name="SaveJson",
file_path=str(target),
file_type="json",
mode="a",
encoding="utf-8",
data={"name": "falyx"},
overwrite=False,
create_dirs=False,
inject_last_result=True,
inject_into="payload",
never_prompt=True,
)
assert action.name == "SaveJson"
assert action.file_path == target
assert action.file_type == FileType.JSON
assert action.mode == "a"
assert action.encoding == "utf-8"
assert action.data == {"name": "falyx"}
assert action.overwrite is False
assert action.create_dirs is False
assert action.inject_last_result is True
assert action.inject_into == "payload"
assert action.local_never_prompt is True
assert "SaveFileAction" in str(action)
assert "output.json" in str(action)
def test_file_path_property_coerces_string_path_and_none(tmp_path: Path) -> None:
action = make_action(None)
assert action.file_path is None
target = tmp_path / "later.txt"
action.file_path = str(target)
assert action.file_path == target
action.file_path = target
assert action.file_path == target
def test_file_path_rejects_unsupported_values(tmp_path: Path) -> None:
action = make_action(tmp_path / "out.txt")
with pytest.raises(TypeError, match="file_path must be a string or Path object"):
action.file_path = 123 # type: ignore[assignment]
def test_get_infer_target_disables_signature_inference(tmp_path: Path) -> None:
action = make_action(tmp_path / "out.txt")
assert action.get_infer_target() == (None, None)
def test_dict_to_xml_serializes_nested_dicts_lists_and_scalars(tmp_path: Path) -> None:
action = make_action(tmp_path / "out.xml", file_type=FileType.XML)
root = ET.Element("root")
action._dict_to_xml(
{
"name": "falyx",
"metadata": {"version": "0.2.0"},
"tags": ["cli", "framework"],
"commands": [{"name": "run"}, {"name": "help"}],
},
root,
)
assert root.findtext("name") == "falyx"
assert root.find("metadata") is not None
assert root.find("metadata/version") is not None
assert root.findtext("metadata/version") == "0.2.0"
assert [element.text for element in root.findall("tags")] == ["cli", "framework"]
assert [element.findtext("name") for element in root.findall("commands")] == [
"run",
"help",
]
@pytest.mark.asyncio
async def test_save_file_requires_file_path_before_saving() -> None:
action = make_action(None, data="hello")
with pytest.raises(ValueError, match="file_path must be set"):
await action.save_file("hello")
@pytest.mark.asyncio
async def test_save_file_refuses_to_overwrite_existing_file_when_disabled(
tmp_path: Path,
) -> None:
target = tmp_path / "existing.txt"
target.write_text("original", encoding="UTF-8")
action = make_action(target, overwrite=False)
with pytest.raises(FileExistsError, match="File already exists"):
await action.save_file("replacement")
assert target.read_text(encoding="UTF-8") == "original"
@pytest.mark.asyncio
async def test_save_file_requires_parent_directory_when_create_dirs_is_disabled(
tmp_path: Path,
) -> None:
target = tmp_path / "missing" / "out.txt"
action = make_action(target, create_dirs=False)
with pytest.raises(FileNotFoundError, match="Directory does not exist"):
await action.save_file("hello")
@pytest.mark.asyncio
async def test_save_file_creates_missing_parent_directories(tmp_path: Path) -> None:
target = tmp_path / "nested" / "out.txt"
action = make_action(target, file_type=FileType.TEXT, create_dirs=True)
await action.save_file("hello")
assert target.read_text(encoding="UTF-8") == "hello"
@pytest.mark.asyncio
@pytest.mark.parametrize(
("file_type", "filename", "data"),
[
(FileType.TEXT, "note.txt", "hello"),
(FileType.JSON, "data.json", {"name": "falyx", "count": 2}),
(FileType.YAML, "data.yaml", {"name": "falyx", "enabled": True}),
(FileType.TOML, "data.toml", {"name": "falyx", "count": 2}),
(FileType.CSV, "rows.csv", [["name", "count"], ["falyx", "2"]]),
(FileType.TSV, "rows.tsv", [["name", "count"], ["falyx", "2"]]),
(
FileType.XML,
"data.xml",
{
"name": "falyx",
"metadata": {"version": "0.2.0"},
"tags": ["cli", "framework"],
},
),
],
)
async def test_save_file_writes_supported_file_types(
tmp_path: Path,
file_type: FileType,
filename: str,
data: Any,
) -> None:
target = tmp_path / filename
action = make_action(target, file_type=file_type)
await action.save_file(data)
if file_type == FileType.TEXT:
assert target.read_text(encoding="UTF-8") == data
elif file_type == FileType.JSON:
assert json.loads(target.read_text(encoding="UTF-8")) == data
elif file_type == FileType.YAML:
assert yaml.safe_load(target.read_text(encoding="UTF-8")) == data
elif file_type == FileType.TOML:
assert toml.loads(target.read_text(encoding="UTF-8")) == data
elif file_type == FileType.CSV:
with target.open(newline="", encoding="UTF-8") as file:
assert list(csv.reader(file)) == data
elif file_type == FileType.TSV:
with target.open(newline="", encoding="UTF-8") as file:
assert list(csv.reader(file, delimiter="\t")) == data
elif file_type == FileType.XML:
root = ET.parse(target).getroot()
assert root.tag == "root"
assert root.findtext("name") == "falyx"
assert root.findtext("metadata/version") == "0.2.0"
assert [element.text for element in root.findall("tags")] == [
"cli",
"framework",
]
@pytest.mark.asyncio
@pytest.mark.parametrize("file_type", [FileType.CSV, FileType.TSV])
@pytest.mark.parametrize(
"data",
[
{"name": "falyx"},
["name", "count"],
[["name", "count"], "not-a-row"],
],
)
async def test_save_file_requires_list_of_lists_for_delimited_formats(
tmp_path: Path,
file_type: FileType,
data: Any,
) -> None:
target = tmp_path / "rows.data"
action = make_action(target, file_type=file_type)
with pytest.raises(ValueError, match="requires a list of lists"):
await action.save_file(data)
@pytest.mark.asyncio
async def test_save_file_requires_dict_for_xml(tmp_path: Path) -> None:
target = tmp_path / "data.xml"
action = make_action(target, file_type=FileType.XML)
with pytest.raises(
ValueError, match="XML file type requires data to be a dictionary"
):
await action.save_file(["not", "a", "dict"])
@pytest.mark.asyncio
async def test_save_file_raises_for_unsupported_internal_file_type(
tmp_path: Path,
) -> None:
target = tmp_path / "data.out"
action = make_action(target, file_type=FileType.TEXT)
action._file_type = object() # Force the defensive unsupported-type branch.
with pytest.raises(ValueError, match="Unsupported file type"):
await action.save_file("hello")
@pytest.mark.asyncio
async def test_save_file_reraises_write_errors(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
target = tmp_path / "out.txt"
action = make_action(target, file_type=FileType.TEXT)
def fake_write_text(self: Path, data: str, *, encoding: str | None = None) -> int:
raise OSError("disk is unavailable")
monkeypatch.setattr(Path, "write_text", fake_write_text)
with pytest.raises(OSError, match="disk is unavailable"):
await action.save_file("hello")
@pytest.mark.asyncio
async def test_run_saves_configured_data_and_triggers_success_lifecycle(
tmp_path: Path,
) -> None:
target = tmp_path / "out.txt"
action = make_action(target, file_type=FileType.TEXT, data="hello")
calls = register_lifecycle_hooks(action)
result = await action("positional", ignored="kwarg")
assert result == str(target)
assert target.read_text(encoding="UTF-8") == "hello"
assert hook_types(calls) == [
HookType.BEFORE,
HookType.ON_SUCCESS,
HookType.AFTER,
HookType.ON_TEARDOWN,
]
assert calls[0][1].args == ("positional",)
assert calls[0][1].kwargs == {"ignored": "kwarg"}
assert calls[0][1].action is action
@pytest.mark.asyncio
async def test_run_uses_data_from_kwargs_when_no_static_data_is_configured(
tmp_path: Path,
) -> None:
target = tmp_path / "out.txt"
action = make_action(target, file_type=FileType.TEXT, data=None)
result = await action(data="from kwargs")
assert result == str(target)
assert target.read_text(encoding="UTF-8") == "from kwargs"
@pytest.mark.asyncio
async def test_run_triggers_error_lifecycle_and_reraises(tmp_path: Path) -> None:
action = make_action(None, data="hello")
calls = register_lifecycle_hooks(action)
with pytest.raises(ValueError, match="file_path must be set"):
await action()
assert hook_types(calls) == [
HookType.BEFORE,
HookType.ON_ERROR,
HookType.AFTER,
HookType.ON_TEARDOWN,
]
assert isinstance(calls[1][1].exception, ValueError)
@pytest.mark.asyncio
async def test_preview_prints_tree_for_existing_file_when_overwrite_enabled(
tmp_path: Path,
) -> None:
target = tmp_path / "out.txt"
target.write_text("existing", encoding="UTF-8")
action = make_action(target, file_type=FileType.TEXT, overwrite=True)
action.console = CaptureConsole()
await action.preview()
assert len(action.console.printed) == 1
printed_tree = action.console.printed[0][0][0]
assert isinstance(printed_tree, Tree)
@pytest.mark.asyncio
async def test_preview_prints_tree_for_existing_file_when_overwrite_disabled(
tmp_path: Path,
) -> None:
target = tmp_path / "out.txt"
target.write_text("existing", encoding="UTF-8")
action = make_action(target, file_type=FileType.TEXT, overwrite=False)
action.console = CaptureConsole()
await action.preview()
assert len(action.console.printed) == 1
printed_tree = action.console.printed[0][0][0]
assert isinstance(printed_tree, Tree)
@pytest.mark.asyncio
async def test_preview_adds_to_existing_parent_without_printing(tmp_path: Path) -> None:
target = tmp_path / "out.txt"
action = make_action(target, file_type=FileType.JSON)
action.console = CaptureConsole()
parent = Tree("root")
await action.preview(parent=parent)
assert action.console.printed == []
assert len(parent.children) == 1
def test_clone_preserves_configuration_but_returns_distinct_action(
tmp_path: Path,
) -> None:
target = tmp_path / "out.json"
action = make_action(
target,
file_type=FileType.JSON,
mode="a",
encoding="utf-8",
data={"name": "falyx"},
overwrite=False,
create_dirs=False,
inject_last_result=True,
inject_into="payload",
never_prompt=True,
)
clone = action.clone()
assert clone is not action
assert clone.name == action.name
assert clone.file_path == action.file_path
assert clone.file_type == action.file_type
assert clone.mode == action.mode
assert clone.encoding == action.encoding
assert clone.data == action.data
assert clone.overwrite is action.overwrite
assert clone.create_dirs is action.create_dirs
assert clone.inject_last_result is action.inject_last_result
assert clone.inject_into == action.inject_into
assert clone.local_never_prompt is True

View File

@@ -1,7 +1,83 @@
import pytest
from __future__ import annotations
from falyx.action import SelectionAction
from falyx.selection import SelectionOption
from typing import Any
import pytest
from rich.tree import Tree
import falyx.action.selection_action as selection_action_module
from falyx.action.action_types import SelectionReturnType
from falyx.action.selection_action import SelectionAction
from falyx.hook_manager import HookType
from falyx.selection import SelectionOption, SelectionOptionMap
from falyx.signals import CancelSignal
class DummyPromptSession:
pass
class CaptureConsole:
def __init__(self) -> None:
self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
def print(self, *args: Any, **kwargs: Any) -> None:
self.printed.append((args, kwargs))
class FakeSharedContext:
def __init__(self, value: Any) -> None:
self.value = value
def last_result(self) -> Any:
return self.value
class SizedButUnsupportedSelections:
def __len__(self) -> int:
return 0
def make_action(selections: Any | None = None, **overrides: Any) -> SelectionAction:
defaults: dict[str, Any] = {
"name": "ChooseThing",
"selections": (
selections if selections is not None else ["alpha", "beta", "gamma"]
),
"prompt_session": DummyPromptSession(),
}
defaults.update(overrides)
return SelectionAction(**defaults)
def make_option_map_action(**overrides: Any) -> SelectionAction:
return make_action(
{
"0": SelectionOption("Development", "dev"),
"1": SelectionOption("Production", "prod"),
"2": SelectionOption("Staging", "stage"),
},
**overrides,
)
def register_lifecycle_hooks(action: SelectionAction) -> list[tuple[HookType, Any]]:
calls: list[tuple[HookType, Any]] = []
def make_hook(hook_type: HookType):
def hook(context: Any) -> None:
calls.append((hook_type, context))
return hook
for hook_type in HookType:
action.hooks.register(hook_type, make_hook(hook_type))
return calls
def hook_types(calls: list[tuple[HookType, Any]]) -> list[HookType]:
return [hook_type for hook_type, _ in calls]
@pytest.mark.asyncio
@@ -285,3 +361,586 @@ async def test_selection_prompt_map_never_prompt_by_value_wildcard():
result = await action()
assert result == ["Beta Service", "Alpha Service"]
def test_init_normalizes_list_tuple_set_and_basic_configuration() -> None:
session = DummyPromptSession()
tuple_action = SelectionAction(
name="TupleChoice",
selections=("red", "blue"),
title="Colors",
columns=2,
prompt_message="[bold]Pick >[/] ",
default_selection="1",
number_selections=1,
separator=";",
allow_duplicates=True,
return_type="value",
prompt_session=session,
never_prompt=True,
show_table=False,
)
assert tuple_action.selections == ["red", "blue"]
assert tuple_action.return_type is SelectionReturnType.VALUE
assert tuple_action.title == "Colors"
assert tuple_action.columns == 2
assert tuple_action.default_selection == "1"
assert tuple_action.separator == ";"
assert tuple_action.allow_duplicates is True
assert tuple_action.prompt_session is session
assert tuple_action.local_never_prompt is True
assert tuple_action.show_table is False
set_action = make_action({"red", "blue"})
assert sorted(set_action.selections) == ["blue", "red"]
def test_init_converts_plain_dict_to_selection_option_map() -> None:
action = make_action({"dev": "Development", "prod": "Production"})
assert isinstance(action.selections, SelectionOptionMap)
assert list(action.selections) == ["0", "1"]
assert action.selections["0"] == SelectionOption("dev", "Development")
assert action.selections["1"] == SelectionOption("prod", "Production")
def test_init_preserves_selection_option_map_values() -> None:
action = make_action(
{
"D": SelectionOption("Development", "dev", style="green"),
"P": SelectionOption("Production", "prod", style="red"),
}
)
assert isinstance(action.selections, SelectionOptionMap)
assert action.selections["D"].description == "Development"
assert action.selections["P"].value == "prod"
@pytest.mark.parametrize("number_selections", [1, 2, "*"])
def test_number_selections_accepts_positive_ints_and_star(
number_selections: int | str,
) -> None:
action = make_action(number_selections=number_selections)
assert action.number_selections == number_selections
@pytest.mark.parametrize("number_selections", [0, -1, "many", object()])
def test_number_selections_rejects_invalid_values(number_selections: Any) -> None:
action = make_action()
with pytest.raises(ValueError, match="number_selections"):
action.number_selections = number_selections
@pytest.mark.parametrize(
("selections", "error_type", "match"),
[
({1: SelectionOption("One", 1)}, ValueError, "Invalid dictionary format"),
(123, TypeError, "selections"),
],
)
def test_selections_setter_rejects_invalid_inputs(
selections: Any,
error_type: type[BaseException],
match: str,
) -> None:
with pytest.raises(error_type, match=match):
make_action(selections)
def test_find_cancel_key_returns_numeric_gap_for_dict_and_next_index_for_list() -> None:
dict_action = make_action(
{
"0": SelectionOption("Zero", 0),
"2": SelectionOption("Two", 2),
}
)
list_action = make_action(["zero", "one"])
assert dict_action._find_cancel_key() == "1"
assert list_action._find_cancel_key() == "2"
def test_cancel_key_setter_rejects_non_string_values() -> None:
action = make_action()
with pytest.raises(TypeError, match="Cancel key must be a string"):
action.cancel_key = 1 # type: ignore[assignment]
def test_cancel_key_setter_rejects_existing_dict_key() -> None:
action = make_action({"A": SelectionOption("Alpha", "alpha")})
with pytest.raises(
ValueError, match="Cancel key cannot be one of the selection keys"
):
action.cancel_key = "A"
@pytest.mark.parametrize("cancel_key", ["x", "3"])
def test_cancel_key_setter_rejects_invalid_list_cancel_key(cancel_key: str) -> None:
action = make_action(["alpha", "beta"])
with pytest.raises(ValueError, match="cancel_key must be a digit"):
action.cancel_key = cancel_key
def test_cancel_formatter_marks_cancel_key_and_formats_regular_items() -> None:
action = make_action(["alpha", "beta"])
action.cancel_key = "2"
assert "Cancel" in action.cancel_formatter(2, "Cancel")
assert action.cancel_formatter(1, "beta").endswith("beta")
def test_get_infer_target_disables_signature_inference() -> None:
action = make_action()
assert action.get_infer_target() == (None, None)
@pytest.mark.parametrize(
("return_type", "keys", "expected"),
[
(SelectionReturnType.KEY, "0", "0"),
(SelectionReturnType.KEY, ["0", "2"], ["0", "2"]),
(SelectionReturnType.VALUE, "1", "prod"),
(SelectionReturnType.VALUE, ["0", "2"], ["dev", "stage"]),
(SelectionReturnType.DESCRIPTION, "0", "Development"),
(
SelectionReturnType.DESCRIPTION,
["0", "2"],
["Development", "Staging"],
),
(
SelectionReturnType.DESCRIPTION_VALUE,
"1",
{"Production": "prod"},
),
(
SelectionReturnType.DESCRIPTION_VALUE,
["0", "2"],
{"Development": "dev", "Staging": "stage"},
),
],
)
def test_get_result_from_keys_returns_configured_shape(
return_type: SelectionReturnType,
keys: str | list[str],
expected: Any,
) -> None:
action = make_option_map_action(return_type=return_type)
assert action._get_result_from_keys(keys) == expected
@pytest.mark.parametrize("keys", ["0", ["0", "1"]])
def test_get_result_from_keys_returns_items_mapping(keys: str | list[str]) -> None:
action = make_option_map_action(return_type=SelectionReturnType.ITEMS)
result = action._get_result_from_keys(keys)
assert isinstance(result, dict)
assert set(result) == ({keys} if isinstance(keys, str) else set(keys))
assert all(isinstance(option, SelectionOption) for option in result.values())
def test_get_result_from_keys_requires_dict_selections() -> None:
action = make_action(["alpha", "beta"])
with pytest.raises(TypeError, match="Selections must be a dictionary"):
action._get_result_from_keys("0")
def test_get_result_from_keys_rejects_unsupported_return_type() -> None:
action = make_option_map_action()
action.return_type = object() # Force defensive branch unreachable through __init__.
with pytest.raises(ValueError, match="Unsupported return type"):
action._get_result_from_keys("0")
@pytest.mark.asyncio
@pytest.mark.parametrize(
("maybe_result", "expected"),
[
("1", "1"),
("prod", "1"),
("Production", "1"),
],
)
async def test_resolve_single_default_maps_dict_key_value_and_description(
maybe_result: str,
expected: str,
) -> None:
action = make_option_map_action()
assert await action._resolve_single_default(maybe_result) == expected
@pytest.mark.asyncio
@pytest.mark.parametrize(
("maybe_result", "expected"),
[
("1", "1"),
("beta", "1"),
("missing", ""),
],
)
async def test_resolve_single_default_maps_list_index_or_value(
maybe_result: str,
expected: str,
) -> None:
action = make_action(["alpha", "beta"])
assert await action._resolve_single_default(maybe_result) == expected
@pytest.mark.asyncio
async def test_resolve_effective_default_uses_first_value_for_single_selection_defaults() -> (
None
):
action = make_action(["alpha", "beta"], default_selection=["beta"])
assert await action._resolve_effective_default() == "1"
@pytest.mark.asyncio
async def test_resolve_effective_default_uses_first_last_result_for_single_selection() -> (
None
):
action = make_action(["alpha", "beta"])
action.shared_context = FakeSharedContext(["beta"])
assert await action._resolve_effective_default() == "1"
@pytest.mark.asyncio
async def test_resolve_effective_default_joins_multi_selection_defaults() -> None:
action = make_action(
["alpha", "beta", "gamma"],
default_selection=["alpha", "gamma"],
number_selections=2,
)
assert await action._resolve_effective_default() == "0,2"
@pytest.mark.asyncio
async def test_resolve_effective_default_joins_multi_selection_last_result() -> None:
action = make_action(["alpha", "beta", "gamma"], number_selections=2)
action.shared_context = FakeSharedContext(["alpha", "gamma"])
assert await action._resolve_effective_default() == "0,2"
@pytest.mark.asyncio
async def test_resolve_effective_default_allows_unbounded_multi_selection_last_result() -> (
None
):
action = make_action(["alpha", "beta", "gamma"], number_selections="*")
action.shared_context = FakeSharedContext(["alpha", "beta", "gamma"])
assert await action._resolve_effective_default() == "0,1,2"
@pytest.mark.asyncio
async def test_resolve_effective_default_rejects_default_length_mismatch() -> None:
action = make_action(
["alpha", "beta", "gamma"],
default_selection=["alpha"],
number_selections=2,
)
with pytest.raises(ValueError, match="default_selection has a different length"):
await action._resolve_effective_default()
@pytest.mark.asyncio
async def test_resolve_effective_default_rejects_last_result_length_mismatch() -> None:
action = make_action(["alpha", "beta", "gamma"], number_selections=2)
action.shared_context = FakeSharedContext(["alpha"])
with pytest.raises(ValueError, match="last_result has a different length"):
await action._resolve_effective_default()
@pytest.mark.asyncio
async def test_resolve_effective_default_warns_when_injected_result_is_unusable(
caplog: pytest.LogCaptureFixture,
) -> None:
action = make_action(
["alpha", "beta"],
inject_last_result=True,
number_selections=2,
)
action.shared_context = FakeSharedContext("missing")
assert await action._resolve_effective_default() == ""
assert "Injected last result" in caplog.text
@pytest.mark.asyncio
async def test_run_list_headless_single_selection_uses_default() -> None:
action = make_action(["alpha", "beta"], never_prompt=True, default_selection="1")
result = await action()
assert result == "beta"
@pytest.mark.asyncio
async def test_run_list_headless_multi_selection_uses_default_list() -> None:
action = make_action(
["alpha", "beta", "gamma"],
never_prompt=True,
default_selection=["alpha", "gamma"],
number_selections=2,
)
result = await action()
assert result == ["alpha", "gamma"]
@pytest.mark.asyncio
async def test_run_dict_headless_single_selection_returns_value() -> None:
action = make_option_map_action(never_prompt=True, default_selection="1")
result = await action()
assert result == "prod"
@pytest.mark.asyncio
async def test_run_dict_headless_multi_selection_returns_configured_shape() -> None:
action = make_option_map_action(
never_prompt=True,
default_selection=["0", "2"],
number_selections=2,
return_type=SelectionReturnType.DESCRIPTION_VALUE,
)
result = await action()
assert result == {"Development": "dev", "Staging": "stage"}
@pytest.mark.asyncio
async def test_run_list_interactive_uses_prompt_for_index(
monkeypatch: pytest.MonkeyPatch,
) -> None:
action = make_action(["alpha", "beta"], never_prompt=False, show_table=False)
async def fake_prompt_for_index(*args: Any, **kwargs: Any) -> int:
assert kwargs["prompt_session"] is action.prompt_session
assert kwargs["show_table"] is False
assert kwargs["cancel_key"] == "2"
return 1
monkeypatch.setattr(
selection_action_module, "prompt_for_index", fake_prompt_for_index
)
result = await action()
assert result == "beta"
@pytest.mark.asyncio
async def test_run_dict_interactive_uses_prompt_for_selection(
monkeypatch: pytest.MonkeyPatch,
) -> None:
action = make_option_map_action(never_prompt=False, show_table=False)
async def fake_prompt_for_selection(*args: Any, **kwargs: Any) -> str:
assert kwargs["prompt_session"] is action.prompt_session
assert kwargs["show_table"] is False
assert kwargs["cancel_key"] == "3"
return "2"
monkeypatch.setattr(
selection_action_module,
"prompt_for_selection",
fake_prompt_for_selection,
)
result = await action()
assert result == "stage"
@pytest.mark.asyncio
async def test_run_raises_when_never_prompt_has_no_effective_default() -> None:
action = make_action(["alpha", "beta"], never_prompt=True)
with pytest.raises(ValueError, match="never_prompt"):
await action()
@pytest.mark.asyncio
async def test_run_list_cancel_triggers_error_and_teardown_hooks(
monkeypatch: pytest.MonkeyPatch,
) -> None:
action = make_action(["alpha", "beta"], never_prompt=True, default_selection="0")
calls = register_lifecycle_hooks(action)
async def fake_resolve_effective_default() -> str:
return "4"
monkeypatch.setattr(
action, "_resolve_effective_default", fake_resolve_effective_default
)
with pytest.raises(IndexError):
await action()
assert HookType.BEFORE in hook_types(calls)
assert HookType.ON_ERROR in hook_types(calls)
assert HookType.AFTER in hook_types(calls)
assert HookType.ON_TEARDOWN in hook_types(calls)
error_contexts = [
context for hook_type, context in calls if hook_type is HookType.ON_ERROR
]
assert isinstance(error_contexts[0].exception, IndexError)
@pytest.mark.asyncio
async def test_run_dict_cancel_triggers_cancel_signal(
monkeypatch: pytest.MonkeyPatch,
) -> None:
action = make_option_map_action(never_prompt=True, default_selection="0")
async def fake_resolve_effective_default() -> str:
return "3"
monkeypatch.setattr(
action, "_resolve_effective_default", fake_resolve_effective_default
)
with pytest.raises(CancelSignal):
await action()
@pytest.mark.asyncio
async def test_run_unsupported_selection_storage_triggers_error_lifecycle(
monkeypatch: pytest.MonkeyPatch,
) -> None:
action = make_action(["alpha"], never_prompt=False)
action._selections = SizedButUnsupportedSelections() # type: ignore[assignment]
calls = register_lifecycle_hooks(action)
async def fake_resolve_effective_default() -> str:
return ""
monkeypatch.setattr(
action, "_resolve_effective_default", fake_resolve_effective_default
)
with pytest.raises(TypeError, match="selections"):
await action()
assert HookType.ON_ERROR in hook_types(calls)
error_contexts = [
context for hook_type, context in calls if hook_type is HookType.ON_ERROR
]
assert isinstance(error_contexts[0].exception, TypeError)
@pytest.mark.asyncio
async def test_run_success_triggers_success_after_and_teardown_hooks() -> None:
action = make_action(["alpha", "beta"], never_prompt=True, default_selection="0")
calls = register_lifecycle_hooks(action)
result = await action()
assert result == "alpha"
assert hook_types(calls).count(HookType.BEFORE) == 1
assert hook_types(calls).count(HookType.ON_SUCCESS) == 1
assert hook_types(calls).count(HookType.AFTER) == 1
assert hook_types(calls).count(HookType.ON_TEARDOWN) == 1
success_contexts = [
context for hook_type, context in calls if hook_type is HookType.ON_SUCCESS
]
assert success_contexts[0].result == "alpha"
@pytest.mark.asyncio
async def test_preview_prints_tree_when_no_parent() -> None:
action = make_option_map_action(default_selection="1", never_prompt=True)
console = CaptureConsole()
action.console = console # type: ignore[assignment]
await action.preview()
assert len(console.printed) == 1
assert "SelectionAction" in str(console.printed[0][0][0].label)
@pytest.mark.asyncio
async def test_preview_adds_to_parent_when_parent_is_provided() -> None:
action = make_action(["alpha", "beta"], default_selection="0")
parent = Tree("Root")
console = CaptureConsole()
action.console = console # type: ignore[assignment]
await action.preview(parent=parent)
assert console.printed == []
assert len(parent.children) == 1
assert "SelectionAction" in str(parent.children[0].label)
def test_str_includes_action_configuration() -> None:
action = make_action(["alpha", "beta"], return_type=SelectionReturnType.KEY)
text = str(action)
assert "SelectionAction" in text
assert "ChooseThing" in text
assert "KEY" in text or "key" in text
def test_clone_copies_selection_action_configuration() -> None:
session = DummyPromptSession()
action = SelectionAction(
name="CloneMe",
selections={"A": SelectionOption("Alpha", "alpha", style="green")},
title="Letters",
columns=3,
prompt_message="Choose letter > ",
default_selection="A",
number_selections="*",
separator=";",
allow_duplicates=True,
inject_last_result=True,
inject_into="choice",
return_type=SelectionReturnType.DESCRIPTION,
prompt_session=session,
never_prompt=True,
show_table=False,
)
clone = action.clone()
assert clone is not action
assert clone.name == action.name
assert clone.title == action.title
assert clone.columns == action.columns
assert clone.prompt_message == action.prompt_message
assert clone.default_selection == action.default_selection
assert clone.number_selections == action.number_selections
assert clone.separator == action.separator
assert clone.allow_duplicates == action.allow_duplicates
assert clone.inject_last_result is True
assert clone.inject_into == "choice"
assert clone.return_type is SelectionReturnType.DESCRIPTION
assert clone.prompt_session is session
assert clone.local_never_prompt is True
assert clone.show_table is False
assert clone.selections is not action.selections
assert clone.selections["A"].description == "Alpha"

View File

@@ -0,0 +1,598 @@
from __future__ import annotations
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any
import pytest
import toml
import yaml
from rich.tree import Tree
import falyx.action.select_file_action as select_file_module
from falyx.action.action_types import FileType
from falyx.action.select_file_action import SelectFileAction
from falyx.hook_manager import HookType
from falyx.selection import SelectionOption
from falyx.signals import CancelSignal
class DummyPromptSession:
pass
class CaptureConsole:
def __init__(self) -> None:
self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
def print(self, *args: Any, **kwargs: Any) -> None:
self.printed.append((args, kwargs))
def make_action(directory: Path, **overrides: Any) -> SelectFileAction:
defaults: dict[str, Any] = {
"name": "ChooseFile",
"directory": directory,
"prompt_session": DummyPromptSession(),
}
defaults.update(overrides)
return SelectFileAction(**defaults)
def write_sample_files(directory: Path) -> dict[str, Path]:
paths = {
"text": directory / "note.txt",
"json": directory / "config.json",
"yaml": directory / "config.yaml",
"toml": directory / "config.toml",
"csv": directory / "rows.csv",
"tsv": directory / "rows.tsv",
"xml": directory / "doc.xml",
}
paths["text"].write_text("hello\n", encoding="UTF-8")
paths["json"].write_text('{"name": "falyx", "count": 2}', encoding="UTF-8")
paths["yaml"].write_text("name: falyx\nenabled: true\n", encoding="UTF-8")
paths["toml"].write_text('name = "falyx"\ncount = 2\n', encoding="UTF-8")
paths["csv"].write_text("name,count\nfalyx,2\n", encoding="UTF-8")
paths["tsv"].write_text("name\tcount\nfalyx\t2\n", encoding="UTF-8")
paths["xml"].write_text("<root><name>falyx</name></root>", encoding="UTF-8")
return paths
def register_lifecycle_hooks(action: SelectFileAction) -> list[tuple[HookType, Any]]:
calls: list[tuple[HookType, Any]] = []
def make_hook(hook_type: HookType):
def hook(context):
calls.append((hook_type, context))
return hook
for hook_type in HookType:
action.hooks.register(hook_type, make_hook(hook_type))
return calls
def hook_types(calls: list[tuple[HookType, Any]]) -> list[HookType]:
return [hook_type for hook_type, _ in calls]
def test_init_normalizes_configuration_and_string_return_type(tmp_path: Path) -> None:
session = DummyPromptSession()
action = SelectFileAction(
"ChooseConfig",
tmp_path,
title="Configs",
columns=4,
prompt_message="[bold]Pick >[/] ",
style="green",
suffix_filter=".json",
return_type="json",
encoding="utf-8",
number_selections="*",
separator=";",
allow_duplicates=True,
prompt_session=session,
never_prompt=True,
)
assert action.name == "ChooseConfig"
assert action.directory == tmp_path.resolve()
assert action.title == "Configs"
assert action.columns == 4
assert action.suffix_filter == ".json"
assert action.return_type == FileType.JSON
assert action.encoding == "utf-8"
assert action.number_selections == "*"
assert action.separator == ";"
assert action.allow_duplicates is True
assert action.prompt_session is session
assert action.local_never_prompt is True
assert "ChooseConfig" in str(action)
assert ".json" in str(action)
@pytest.mark.parametrize("number_selections", [1, 2, "*"])
def test_number_selections_accepts_positive_ints_and_star(
tmp_path: Path,
number_selections: int | str,
) -> None:
action = make_action(tmp_path, number_selections=number_selections)
assert action.number_selections == number_selections
@pytest.mark.parametrize("number_selections", [0, -1, "many", object()])
def test_number_selections_rejects_invalid_values(
tmp_path: Path,
number_selections: Any,
) -> None:
action = make_action(tmp_path)
with pytest.raises(ValueError, match="number_selections"):
action.number_selections = number_selections
def test_get_options_uses_numeric_keys_and_selection_options(tmp_path: Path) -> None:
first = tmp_path / "a.txt"
second = tmp_path / "b.txt"
first.write_text("a", encoding="UTF-8")
second.write_text("b", encoding="UTF-8")
action = make_action(tmp_path, style="cyan")
options = action.get_options([first, second])
assert list(options) == ["0", "1"]
assert options["0"] == SelectionOption(
description="a.txt",
value=first,
style="cyan",
)
assert options["1"].description == "b.txt"
assert options["1"].value == second
def test_find_cancel_key_returns_first_numeric_gap_or_next_index(tmp_path: Path) -> None:
action = make_action(tmp_path)
assert action._find_cancel_key({"0": object(), "2": object()}) == "1"
assert action._find_cancel_key({"0": object(), "1": object()}) == "2"
assert action._find_cancel_key({}) == "0"
def test_get_infer_target_disables_signature_inference(tmp_path: Path) -> None:
action = make_action(tmp_path)
assert action.get_infer_target() == (None, None)
@pytest.mark.parametrize(
("return_type", "file_key", "expected"),
[
(FileType.TEXT, "text", "hello\n"),
(FileType.PATH, "text", "PATH"),
(FileType.JSON, "json", {"name": "falyx", "count": 2}),
(FileType.YAML, "yaml", {"name": "falyx", "enabled": True}),
(FileType.TOML, "toml", {"name": "falyx", "count": 2}),
(FileType.CSV, "csv", [["name", "count"], ["falyx", "2"]]),
(FileType.TSV, "tsv", [["name", "count"], ["falyx", "2"]]),
],
)
def test_parse_file_returns_requested_representation(
tmp_path: Path,
return_type: FileType,
file_key: str,
expected: Any,
) -> None:
files = write_sample_files(tmp_path)
action = make_action(tmp_path, return_type=return_type)
result = action.parse_file(files[file_key])
if expected == "PATH":
assert result == files[file_key]
else:
assert result == expected
def test_parse_file_returns_xml_root(tmp_path: Path) -> None:
files = write_sample_files(tmp_path)
action = make_action(tmp_path, return_type=FileType.XML)
result = action.parse_file(files["xml"])
assert isinstance(result, ET.Element)
assert result.tag == "root"
assert result.findtext("name") == "falyx"
def test_clone_preserves_configuration_but_returns_distinct_action(
tmp_path: Path,
) -> None:
session = DummyPromptSession()
action = make_action(
tmp_path,
title="Pick a data file",
columns=2,
prompt_message="Select > ",
style="magenta",
suffix_filter=".json",
return_type=FileType.JSON,
encoding="utf-8",
number_selections=2,
separator="|",
allow_duplicates=True,
prompt_session=session,
never_prompt=True,
)
clone = action.clone()
assert clone is not action
assert clone.name == action.name
assert clone.directory == action.directory
assert clone.title == action.title
assert clone.columns == action.columns
assert clone.prompt_message == action.prompt_message
assert clone.style == action.style
assert clone.suffix_filter == action.suffix_filter
assert clone.return_type == action.return_type
assert clone.encoding == action.encoding
assert clone.number_selections == action.number_selections
assert clone.separator == action.separator
assert clone.allow_duplicates == action.allow_duplicates
assert clone.prompt_session is session
assert clone.local_never_prompt is True
@pytest.mark.asyncio
async def test_preview_prints_tree_when_no_parent_is_given(tmp_path: Path) -> None:
write_sample_files(tmp_path)
action = make_action(tmp_path, suffix_filter=".json", return_type=FileType.JSON)
action.console = CaptureConsole()
await action.preview()
assert len(action.console.printed) == 1
printed_tree = action.console.printed[0][0][0]
assert isinstance(printed_tree, Tree)
@pytest.mark.asyncio
async def test_preview_adds_to_existing_parent_and_limits_file_sample(
tmp_path: Path,
) -> None:
for index in range(12):
(tmp_path / f"config-{index}.json").write_text("{}", encoding="UTF-8")
(tmp_path / "ignore.txt").write_text("ignored", encoding="UTF-8")
action = make_action(tmp_path, suffix_filter=".json", return_type=FileType.JSON)
parent = Tree("root")
await action.preview(parent=parent)
assert len(parent.children) == 1
action_tree = parent.children[0]
rendered_labels = [str(child.label) for child in action_tree.children]
assert any("Suffix filter" in label and ".json" in label for label in rendered_labels)
file_list = next(
child for child in action_tree.children if str(child.label) == "[dim]Files:[/]"
)
assert len(file_list.children) == 11
assert "... (2 more)" in str(file_list.children[-1].label)
@pytest.mark.asyncio
async def test_preview_reports_directory_scan_errors(tmp_path: Path) -> None:
missing_dir = tmp_path / "missing"
action = make_action(missing_dir)
parent = Tree("root")
await action.preview(parent=parent)
action_tree = parent.children[0]
assert any(
"Error scanning directory" in str(child.label) for child in action_tree.children
)
@pytest.mark.asyncio
async def test_run_raises_for_missing_directory_and_triggers_error_lifecycle(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
action = make_action(tmp_path / "missing")
calls = register_lifecycle_hooks(action)
recorded: list[Any] = []
monkeypatch.setattr(select_file_module.er, "record", recorded.append)
with pytest.raises(FileNotFoundError, match="does not exist"):
await action("arg", flag=True)
assert hook_types(calls) == [
HookType.BEFORE,
HookType.ON_ERROR,
HookType.AFTER,
HookType.ON_TEARDOWN,
]
assert recorded
assert isinstance(recorded[0].exception, FileNotFoundError)
assert recorded[0].args == ("arg",)
assert recorded[0].kwargs == {"flag": True}
@pytest.mark.asyncio
async def test_run_raises_when_directory_path_is_file(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
directory_path = tmp_path / "not-a-dir.txt"
directory_path.write_text("not a directory", encoding="UTF-8")
action = make_action(directory_path)
calls = register_lifecycle_hooks(action)
monkeypatch.setattr(select_file_module.er, "record", lambda context: None)
with pytest.raises(NotADirectoryError, match="is not a directory"):
await action()
assert HookType.ON_ERROR in hook_types(calls)
@pytest.mark.asyncio
async def test_run_raises_when_suffix_filter_matches_no_files(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
(tmp_path / "note.txt").write_text("hello", encoding="UTF-8")
action = make_action(tmp_path, suffix_filter=".json")
calls = register_lifecycle_hooks(action)
monkeypatch.setattr(select_file_module.er, "record", lambda context: None)
with pytest.raises(FileNotFoundError, match="No files found"):
await action()
assert HookType.ON_ERROR in hook_types(calls)
@pytest.mark.asyncio
async def test_run_single_selection_returns_parsed_file_and_passes_prompt_options(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
selected = tmp_path / "note.txt"
selected.write_text("selected", encoding="UTF-8")
(tmp_path / "other.json").write_text("{}", encoding="UTF-8")
action = make_action(
tmp_path,
suffix_filter=".txt",
return_type=FileType.TEXT,
number_selections=1,
separator=";",
allow_duplicates=True,
)
calls = register_lifecycle_hooks(action)
recorded: list[Any] = []
prompt_calls: list[dict[str, Any]] = []
render_calls: list[dict[str, Any]] = []
monkeypatch.setattr(select_file_module.er, "record", recorded.append)
def fake_render_selection_dict_table(**kwargs: Any) -> object:
render_calls.append(kwargs)
return object()
async def fake_prompt_for_selection(valid_keys, table, **kwargs: Any) -> str:
prompt_calls.append({"valid_keys": list(valid_keys), "table": table, **kwargs})
return "0"
monkeypatch.setattr(
select_file_module,
"render_selection_dict_table",
fake_render_selection_dict_table,
)
monkeypatch.setattr(
select_file_module,
"prompt_for_selection",
fake_prompt_for_selection,
)
result = await action()
assert result == "selected"
assert hook_types(calls) == [
HookType.BEFORE,
HookType.ON_SUCCESS,
HookType.AFTER,
HookType.ON_TEARDOWN,
]
assert recorded[0].result == "selected"
assert render_calls[0]["title"] == action.title
assert render_calls[0]["columns"] == action.columns
assert set(render_calls[0]["selections"]) == {"0", "1"}
assert prompt_calls[0]["valid_keys"] == ["0", "1"]
assert prompt_calls[0]["prompt_session"] is action.prompt_session
assert prompt_calls[0]["prompt_message"] == action.prompt_message
assert prompt_calls[0]["number_selections"] == 1
assert prompt_calls[0]["separator"] == ";"
assert prompt_calls[0]["allow_duplicates"] is True
assert prompt_calls[0]["cancel_key"] == "1"
@pytest.mark.asyncio
async def test_run_multi_selection_returns_results_for_each_selected_file(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
first = tmp_path / "a.txt"
second = tmp_path / "b.txt"
first.write_text("a", encoding="UTF-8")
second.write_text("b", encoding="UTF-8")
action = make_action(tmp_path, return_type=FileType.PATH, number_selections=2)
monkeypatch.setattr(select_file_module.er, "record", lambda context: None)
monkeypatch.setattr(
select_file_module,
"render_selection_dict_table",
lambda **kwargs: object(),
)
async def fake_prompt_for_selection(*args: Any, **kwargs: Any) -> list[str]:
return ["0", "1"]
monkeypatch.setattr(
select_file_module,
"prompt_for_selection",
fake_prompt_for_selection,
)
result = await action()
print(result)
assert result == [first, second] or result == [second, first]
@pytest.mark.asyncio
async def test_run_cancel_selection_raises_cancel_signal_and_skips_error_hook(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
(tmp_path / "a.txt").write_text("a", encoding="UTF-8")
action = make_action(tmp_path)
calls = register_lifecycle_hooks(action)
recorded: list[Any] = []
monkeypatch.setattr(select_file_module.er, "record", recorded.append)
monkeypatch.setattr(
select_file_module,
"render_selection_dict_table",
lambda **kwargs: object(),
)
async def fake_prompt_for_selection(*args: Any, **kwargs: Any) -> str:
return kwargs["cancel_key"]
monkeypatch.setattr(
select_file_module,
"prompt_for_selection",
fake_prompt_for_selection,
)
with pytest.raises(CancelSignal, match="User canceled"):
await action()
assert hook_types(calls) == [
HookType.BEFORE,
HookType.AFTER,
HookType.ON_TEARDOWN,
]
assert recorded
assert recorded[0].exception is None
def assert_parse_file_value_error(
action: SelectFileAction,
file: Path,
*,
expected_cause_type: (
type[BaseException] | tuple[type[BaseException], ...] | None
) = None,
) -> ValueError:
with pytest.raises(ValueError) as exc_info:
action.parse_file(file)
error = exc_info.value
assert f"Failed to parse {file.name} as" in str(error)
assert error.__cause__ is not None
if expected_cause_type is not None:
assert isinstance(error.__cause__, expected_cause_type)
return error
def test_parse_file_wraps_invalid_json_errors(tmp_path: Path) -> None:
import json
broken = tmp_path / "broken.json"
broken.write_text('{"name": ', encoding="UTF-8")
action = make_action(tmp_path, return_type=FileType.JSON)
assert_parse_file_value_error(
action, broken, expected_cause_type=json.JSONDecodeError
)
def test_parse_file_wraps_invalid_toml_errors(tmp_path: Path) -> None:
broken = tmp_path / "broken.toml"
broken.write_text('name = "falyx"\ncount = ', encoding="UTF-8")
action = make_action(tmp_path, return_type=FileType.TOML)
assert_parse_file_value_error(
action, broken, expected_cause_type=toml.TomlDecodeError
)
def test_parse_file_wraps_invalid_yaml_errors(tmp_path: Path) -> None:
broken = tmp_path / "broken.yaml"
broken.write_text("name: [unterminated\n", encoding="UTF-8")
action = make_action(tmp_path, return_type=FileType.YAML)
assert_parse_file_value_error(action, broken, expected_cause_type=yaml.YAMLError)
def test_parse_file_wraps_invalid_xml_errors(tmp_path: Path) -> None:
broken = tmp_path / "broken.xml"
broken.write_text("<root><name>falyx</root>", encoding="UTF-8")
action = make_action(tmp_path, return_type=FileType.XML)
assert_parse_file_value_error(action, broken, expected_cause_type=ET.ParseError)
@pytest.mark.parametrize(
"return_type",
[
FileType.TEXT,
FileType.JSON,
FileType.YAML,
FileType.TOML,
FileType.CSV,
FileType.TSV,
FileType.XML,
],
)
def test_parse_file_wraps_missing_file_errors(
tmp_path: Path, return_type: FileType
) -> None:
missing = tmp_path / "missing.data"
action = make_action(tmp_path, return_type=return_type)
assert_parse_file_value_error(action, missing, expected_cause_type=FileNotFoundError)
@pytest.mark.parametrize("return_type", [FileType.CSV, FileType.TSV])
def test_parse_file_wraps_csv_style_open_errors(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
return_type: FileType,
) -> None:
data_file = tmp_path / "rows.data"
data_file.write_text("name,count\nfalyx,2\n", encoding="UTF-8")
action = make_action(tmp_path, return_type=return_type)
def fake_open(*args: Any, **kwargs: Any) -> Any:
raise OSError("cannot open test file")
monkeypatch.setattr("builtins.open", fake_open)
error = assert_parse_file_value_error(action, data_file, expected_cause_type=OSError)
assert "cannot open test file" in str(error)
def test_parse_file_wraps_unsupported_return_type_errors(tmp_path: Path) -> None:
data_file = tmp_path / "note.txt"
data_file.write_text("hello", encoding="UTF-8")
action = make_action(tmp_path, return_type=FileType.TEXT)
action.return_type = object() # Force the defensive unsupported-type branch.
error = assert_parse_file_value_error(
action, data_file, expected_cause_type=ValueError
)
assert "Unsupported return type" in str(error.__cause__)

View File

@@ -1,15 +1,89 @@
# test_command.py
import pytest
import logging
from collections.abc import Callable
from types import SimpleNamespace
from typing import Any
from falyx.action import Action, BaseIOAction, ChainedAction
import pytest
from pydantic import ValidationError
import falyx.command as command_module
from falyx.action import Action, BaseAction, BaseIOAction, ChainedAction
from falyx.command import Command
from falyx.exceptions import CommandArgumentError, InvalidHookError, NotAFalyxError
from falyx.execution_option import ExecutionOption
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType
from falyx.parser.command_argument_parser import CommandArgumentParser
from falyx.retry import RetryPolicy
from falyx.signals import CancelSignal
asyncio_default_fixture_loop_scope = "function"
# --- Fixtures ---
class CaptureConsole:
def __init__(self) -> None:
self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
def print(self, *args: Any, **kwargs: Any) -> None:
self.printed.append((args, kwargs))
class FakeBaseAction(BaseAction):
def __init__(
self,
name: str = "FakeAction",
*,
result: Any = "ok",
infer_target: Callable[..., Any] | None = None,
metadata: dict[str, Any] | None = None,
never_prompt: bool | None = None,
) -> None:
super().__init__(name, never_prompt=never_prompt)
self.result = result
self.infer_target = infer_target or (lambda: None)
self.metadata = metadata
self.preview_calls = 0
self.calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
async def _run(self, *args: Any, **kwargs: Any) -> Any:
self.calls.append((args, kwargs))
return self.result
async def preview(self, parent=None):
self.preview_calls += 1
if parent is not None:
parent.add("fake preview")
return None
def get_infer_target(self):
return self.infer_target, self.metadata
def clone(self) -> "FakeBaseAction":
return FakeBaseAction(
self.name,
result=self.result,
infer_target=self.infer_target,
metadata=self.metadata,
never_prompt=self.local_never_prompt,
)
def make_command(**overrides: Any) -> Command:
defaults = dict(
key="D",
description="Deploy command",
action=lambda *args, **kwargs: {"args": args, "kwargs": kwargs},
auto_args=False,
)
defaults.update(overrides)
return Command.build(**defaults)
def formatted_plain_text(formatted_text) -> str:
return "".join(fragment for _, fragment in list(formatted_text))
@pytest.fixture(autouse=True)
def clean_registry():
er.clear()
@@ -17,12 +91,10 @@ def clean_registry():
er.clear()
# --- Dummy Action ---
async def dummy_action():
return "ok"
# --- Dummy IO Action ---
class DummyInputAction(BaseIOAction):
async def _run(self, *args, **kwargs):
return "needs input"
@@ -31,7 +103,6 @@ class DummyInputAction(BaseIOAction):
pass
# --- Tests ---
@pytest.mark.asyncio
async def test_command_creation():
"""Test if Command can be created with a callable."""
@@ -172,3 +243,654 @@ def test_command_bad_action():
with pytest.raises(TypeError) as exc_info:
Command(key="TEST", description="Test Command", action="not_callable")
assert str(exc_info.value) == "Action must be a callable or an instance of BaseAction"
def test_command_bad_options_manager():
"""Test if Command raises an exception when options_manager is not a dict or callable."""
with pytest.raises(ValidationError) as exc_info:
Command(
key="TEST",
description="Test Command",
action=dummy_action,
options_manager="not_a_dict_or_callable",
)
assert "Input should be an instance of OptionsManager" in str(exc_info.value)
@pytest.mark.asyncio
async def test_resolve_args_uses_custom_parser_and_splits_string_input() -> None:
seen: list[list[str]] = []
def custom_parser(tokens: list[str]):
seen.append(tokens)
return (("parsed",), {"tokens": tokens}, {"summary": True})
command = make_command(custom_parser=custom_parser)
args, kwargs, execution_args = await command.resolve_args("--name 'Ada Lovelace'")
assert seen == [["--name", "Ada Lovelace"]]
assert args == ("parsed",)
assert kwargs == {"tokens": ["--name", "Ada Lovelace"]}
assert execution_args == {"summary": True}
@pytest.mark.asyncio
async def test_resolve_args_rejects_non_callable_custom_parser() -> None:
command = make_command(custom_parser=lambda tokens: ((), {}, {}))
command.custom_parser = object()
with pytest.raises(NotAFalyxError, match="custom_parser must be a callable"):
await command.resolve_args([])
@pytest.mark.asyncio
async def test_resolve_args_wraps_bad_shell_input_for_custom_parser() -> None:
command = make_command(custom_parser=lambda tokens: ((), {}, {}))
with pytest.raises(CommandArgumentError, match="Failed to parse arguments"):
await command.resolve_args("'unterminated")
@pytest.mark.asyncio
async def test_resolve_args_wraps_bad_shell_input_for_command_argument_parser() -> None:
command = make_command()
with pytest.raises(CommandArgumentError, match="Failed to parse arguments"):
await command.resolve_args("'unterminated")
@pytest.mark.asyncio
async def test_resolve_args_rejects_missing_parser_when_no_custom_parser_exists() -> None:
command = make_command(custom_parser=lambda tokens: ((), {}, {}))
command.custom_parser = None
command.arg_parser = None
with pytest.raises(NotAFalyxError, match="Command has no parser configured"):
await command.resolve_args([])
@pytest.mark.asyncio
async def test_resolve_args_rejects_invalid_arg_parser_instance() -> None:
command = make_command()
command.arg_parser = object()
with pytest.raises(NotAFalyxError, match="arg_parser must be an instance"):
await command.resolve_args([])
@pytest.mark.asyncio
async def test_explicit_argument_definitions_are_added_to_default_parser() -> None:
command = make_command(
arguments=[
{
"flags": ("target",),
"help": "Deployment target",
},
{
"flags": ("--region",),
"default": "us-east",
},
]
)
args, kwargs, execution_args = await command.resolve_args(
["api", "--region", "us-west"]
)
assert args == ("api",)
assert kwargs == {"region": "us-west"}
assert execution_args == {}
@pytest.mark.asyncio
async def test_argument_config_callback_configures_existing_parser() -> None:
def configure(parser: CommandArgumentParser) -> None:
parser.add_argument("--region", default="us-east")
command = make_command(argument_config=configure)
args, kwargs, execution_args = await command.resolve_args(["--region", "us-west"])
assert args == ()
assert kwargs == {"region": "us-west"}
assert execution_args == {}
def test_base_action_inference_merges_action_metadata() -> None:
def deploy(region: str) -> None:
return None
action = FakeBaseAction(
infer_target=deploy,
metadata={"region": {"help": "Region from action metadata"}},
)
command = Command.build(
key="D",
description="Deploy command",
action=action,
auto_args=True,
)
assert command.arg_metadata["region"] == {"help": "Region from action metadata"}
assert isinstance(command.arg_parser, CommandArgumentParser)
assert "region" in command.arg_parser._positional
def test_build_validates_parser_runtime_dependencies_and_retry_policy() -> None:
with pytest.raises(NotAFalyxError, match="arg_parser"):
make_command(arg_parser=object())
with pytest.raises(NotAFalyxError, match="options_manager"):
make_command(options_manager=object())
with pytest.raises(InvalidHookError, match="HookManager"):
make_command(hooks=object())
with pytest.raises(NotAFalyxError, match="retry_policy"):
make_command(retry_policy=object())
def test_build_normalizes_execution_options_and_registers_hook_lists() -> None:
async def before(_context) -> None:
return None
async def success(_context) -> None:
return None
async def error(_context) -> None:
return None
async def after(_context) -> None:
return None
async def teardown(_context) -> None:
return None
command = make_command(
execution_options=["summary", ExecutionOption.CONFIRM],
before_hooks=[before],
success_hooks=[success],
error_hooks=[error],
after_hooks=[after],
teardown_hooks=[teardown],
spinner=True,
)
assert ExecutionOption.SUMMARY in command.execution_options
assert ExecutionOption.CONFIRM in command.execution_options
assert before in command.hooks._hooks[HookType.BEFORE]
assert success in command.hooks._hooks[HookType.ON_SUCCESS]
assert error in command.hooks._hooks[HookType.ON_ERROR]
assert after in command.hooks._hooks[HookType.AFTER]
assert teardown in command.hooks._hooks[HookType.ON_TEARDOWN]
assert command.hooks._hooks[HookType.BEFORE]
assert command.hooks._hooks[HookType.ON_TEARDOWN]
def test_model_post_init_warns_for_retry_flags_on_plain_callable(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING):
make_command(retry=True, retry_all=True)
assert "Retry requested" in caplog.text
assert "Retry all requested" in caplog.text
def test_retry_all_for_base_action_enables_policy_recursively(
monkeypatch: pytest.MonkeyPatch,
) -> None:
action = FakeBaseAction()
calls: list[tuple[BaseAction, RetryPolicy]] = []
def fake_enable_retries_recursively(
base_action: BaseAction, policy: RetryPolicy
) -> None:
calls.append((base_action, policy))
monkeypatch.setattr(
command_module,
"enable_retries_recursively",
fake_enable_retries_recursively,
)
command = Command.build(
key="D",
description="Deploy command",
action=action,
retry_all=True,
auto_args=False,
)
assert command.retry_policy.enabled is True
assert calls == [(action, command.retry_policy)]
def test_logging_hooks_are_registered_on_base_action() -> None:
action = FakeBaseAction()
Command.build(
key="D",
description="Deploy command",
action=action,
logging_hooks=True,
auto_args=False,
)
assert any(action.hooks._hooks.values())
def test_ignore_in_history_is_copied_to_base_action() -> None:
action = FakeBaseAction()
Command.build(
key="D",
description="Deploy command",
action=action,
ignore_in_history=True,
auto_args=False,
)
assert action.ignore_in_history is True
def test_retry_flag_enables_retry_on_action_instance() -> None:
action = Action("DeployAction", lambda: "ok")
Command.build(
key="D",
description="Deploy command",
action=action,
retry=True,
auto_args=False,
)
assert action.retry_policy.enabled is True
def test_confirmation_prompt_uses_custom_message() -> None:
command = make_command(confirm_message="Ship it?")
assert list(command._confirmation_prompt) == [("class:confirm", "Ship it?")]
def test_confirmation_prompt_describes_default_callable_with_static_inputs() -> None:
def deploy() -> str:
return "ok"
command = Command.build(
key="D",
description="Deploy command",
action=deploy,
args=("api",),
kwargs={"region": "us-east"},
auto_args=False,
)
plain_text = formatted_plain_text(command._confirmation_prompt)
assert "Confirm execution of" in plain_text
assert "D" in plain_text
assert "Deploy command" in plain_text
assert "calls" in plain_text
assert "args=('api',)" in plain_text
assert "kwargs={'region': 'us-east'}" in plain_text
def test_confirmation_prompt_uses_base_action_name() -> None:
command = Command.build(
key="D",
description="Deploy command",
action=FakeBaseAction("DeployAction"),
auto_args=False,
)
assert "DeployAction" in formatted_plain_text(command._confirmation_prompt)
@pytest.mark.asyncio
async def test_confirmation_cancel_previews_then_raises_cancel_signal(
monkeypatch: pytest.MonkeyPatch,
) -> None:
command = make_command(confirm=True, preview_before_confirm=True)
previewed: list[str] = []
confirmed_prompts: list[Any] = []
async def fake_preview(self: Command) -> None:
previewed.append(self.key)
async def fake_confirm(prompt) -> bool:
confirmed_prompts.append(prompt)
return False
monkeypatch.setattr(Command, "preview", fake_preview)
monkeypatch.setattr(command_module, "confirm_async", fake_confirm)
with pytest.raises(CancelSignal, match="Cancelled by confirmation"):
await command()
assert previewed == ["D"]
assert confirmed_prompts
@pytest.mark.asyncio
async def test_confirmation_accepts_and_executes_action(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[str] = []
async def fake_confirm(_prompt) -> bool:
return True
def action() -> str:
calls.append("ran")
return "done"
monkeypatch.setattr(command_module, "confirm_async", fake_confirm)
command = Command.build(
key="D",
description="Deploy command",
action=action,
confirm=True,
preview_before_confirm=False,
auto_args=False,
)
assert await command() == "done"
assert calls == ["ran"]
def test_get_option_returns_default_when_no_options_manager_is_available() -> None:
command = make_command()
command.options_manager = None
assert command.get_option("missing", "fallback") == "fallback"
def test_primary_alias_falls_back_to_command_key() -> None:
assert make_command(aliases=[]).primary_alias == "D"
def test_usage_reports_no_arguments_when_parser_is_absent() -> None:
command = make_command(custom_parser=lambda tokens: ((), {}, {}))
assert command.usage == "No arguments defined."
def test_usage_delegates_to_arg_parser_when_available() -> None:
command = make_command(aliases=["deploy"])
assert "D" in command.usage
assert "deploy" in command.usage
def test_help_signature_full_mode_includes_help_text_and_tags() -> None:
command = make_command(help_text="Detailed deploy help", tags=["deploy", "cloud"])
usage, description, tags = command.help_signature
assert "D" in usage
assert "Detailed deploy help" in description
assert "deploy, cloud" in tags
def test_help_signature_simple_mode_uses_key_and_aliases() -> None:
command = make_command(
aliases=["deploy"],
help_text="Detailed deploy help",
simple_help_signature=True,
)
usage, description, tags = command.help_signature
assert "D" in usage
assert "deploy" in usage
assert "Detailed deploy help" in description
assert tags == ""
def test_log_summary_delegates_to_existing_context() -> None:
command = make_command()
calls: list[str] = []
command._context = SimpleNamespace(log_summary=lambda: calls.append("logged"))
command.log_summary()
assert calls == ["logged"]
def test_render_usage_prefers_custom_usage(monkeypatch: pytest.MonkeyPatch) -> None:
captured = CaptureConsole()
monkeypatch.setattr(command_module, "console", captured)
command = make_command(custom_usage=lambda: "custom usage")
command.render_usage()
assert captured.printed[0][0] == ("custom usage",)
def test_render_usage_falls_back_to_command_key_without_parser(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured = CaptureConsole()
monkeypatch.setattr(command_module, "console", captured)
command = make_command(custom_parser=lambda tokens: ((), {}, {}))
command.render_usage()
assert captured.printed[0][0] == ("[bold]usage:[/] D",)
def test_render_help_and_tldr_custom_renderers_return_true(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured = CaptureConsole()
monkeypatch.setattr(command_module, "console", captured)
command = make_command(
custom_help=lambda: "custom help",
custom_tldr=lambda: "custom tldr",
)
assert command.render_help() is True
assert command.render_tldr() is True
assert [printed[0][0] for printed in captured.printed] == [
"custom help",
"custom tldr",
]
def test_render_help_and_tldr_return_false_without_parser_or_custom_renderer() -> None:
command = make_command(custom_parser=lambda tokens: ((), {}, {}))
assert command.render_help() is False
assert command.render_tldr() is False
@pytest.mark.asyncio
async def test_preview_renders_plain_callable_details(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured = CaptureConsole()
monkeypatch.setattr(command_module, "console", captured)
def deploy() -> str:
return "ok"
command = Command.build(
key="D",
description="Deploy command",
action=deploy,
args=("api",),
kwargs={"region": "us-east"},
help_text="Preview help",
auto_args=False,
)
await command.preview()
rendered = "\n".join(str(args[0]) for args, _ in captured.printed)
assert "Command:" in rendered
assert "Preview help" in rendered
assert "Would call:" in rendered
assert "args=('api',), kwargs={'region': 'us-east'}" in rendered
@pytest.mark.asyncio
async def test_preview_renders_base_action_tree(monkeypatch: pytest.MonkeyPatch) -> None:
captured = CaptureConsole()
monkeypatch.setattr(command_module, "console", captured)
action = FakeBaseAction("DeployAction")
command = Command.build(
key="D",
description="Deploy command",
action=action,
help_text="Preview help",
auto_args=False,
)
await command.preview()
assert action.preview_calls == 1
assert captured.printed
@pytest.mark.asyncio
async def test_call_merges_static_and_invocation_inputs_and_triggers_hooks() -> None:
events: list[tuple[str, Any]] = []
async def before(context) -> None:
events.append(("before", context.args))
async def success(context) -> None:
events.append(("success", context.result))
async def after(context) -> None:
events.append(("after", context.result))
async def teardown(context) -> None:
events.append(("teardown", context.result))
def action(*args: Any, **kwargs: Any) -> dict[str, Any]:
return {"args": args, "kwargs": kwargs}
command = Command.build(
key="D",
description="Deploy command",
action=action,
args=("static",),
kwargs={"region": "us-east"},
before_hooks=[before],
success_hooks=[success],
after_hooks=[after],
teardown_hooks=[teardown],
auto_args=False,
)
result = await command("runtime", region="us-west")
assert result == {
"args": ("runtime", "static"),
"kwargs": {"region": "us-west"},
}
assert command.result == result
assert events == [
("before", ("runtime", "static")),
("success", result),
("after", result),
("teardown", result),
]
@pytest.mark.asyncio
async def test_call_triggers_error_after_and_teardown_hooks_on_failure() -> None:
events: list[tuple[str, str | None]] = []
async def on_error(context) -> None:
events.append(("error", str(context.exception)))
async def after(context) -> None:
events.append(("after", str(context.exception)))
async def teardown(context) -> None:
events.append(("teardown", str(context.exception)))
def action() -> None:
raise RuntimeError("boom")
command = Command.build(
key="D",
description="Deploy command",
action=action,
error_hooks=[on_error],
after_hooks=[after],
teardown_hooks=[teardown],
auto_args=False,
)
with pytest.raises(RuntimeError, match="boom"):
await command()
assert events == [
("error", "boom"),
("after", "boom"),
("teardown", "boom"),
]
def test_str_includes_command_identity() -> None:
text = str(make_command())
assert "Command(key='D'" in text
assert "Deploy command" in text
def test_clone_with_overrides_clones_parser_hooks_and_base_action() -> None:
action = FakeBaseAction("DeployAction")
async def before(_context) -> None:
return None
command = Command.build(
key="D",
description="Deploy command",
action=action,
aliases=["deploy"],
before_hooks=[before],
auto_args=False,
)
clone = command.clone_with_overrides(
key="P",
description="Promote command",
aliases=["promote"],
)
assert clone.key == "P"
assert clone.description == "Promote command"
assert clone.aliases == ["promote"]
assert clone.action is not command.action
assert isinstance(clone.action, FakeBaseAction)
assert clone.hooks is not command.hooks
assert before in clone.hooks._hooks[HookType.BEFORE]
assert isinstance(clone.arg_parser, CommandArgumentParser)
assert clone.arg_parser.command_key == "P"
def test_clone_with_overrides_can_replace_action_and_execution_options() -> None:
command = make_command(execution_options=["summary"])
def replacement() -> str:
return "replacement"
clone = command.clone_with_overrides(
action=replacement,
execution_options=[ExecutionOption.CONFIRM],
simple_help_signature=True,
)
assert clone.action is not command.action
assert ExecutionOption.CONFIRM in clone.execution_options
assert ExecutionOption.SUMMARY not in clone.execution_options
assert clone.simple_help_signature is True

View File

@@ -0,0 +1,305 @@
import re
import pytest
from prompt_toolkit.completion import Completion
from prompt_toolkit.document import Document
from falyx import Falyx
from falyx.completer import FalyxCompleter
from falyx.parser import CommandArgumentParser
def completion_texts(completions) -> list[str]:
return [c.text for c in completions]
@pytest.fixture
def falyx():
flx = Falyx()
run_parser = CommandArgumentParser(
command_key="R",
command_description="Run Command",
)
run_parser.add_argument("--tag")
run_parser.add_argument("--name")
flx.add_command(
"R",
"Run Command",
lambda: None,
aliases=["RUN"],
arg_parser=run_parser,
)
ops = Falyx(program="ops")
deploy_parser = CommandArgumentParser(
command_key="D",
command_description="Deploy Command",
)
deploy_parser.add_argument("--target")
deploy_parser.add_argument("--region")
ops.add_command(
"D",
"Deploy Command",
lambda: None,
aliases=["DEPLOY"],
arg_parser=deploy_parser,
)
flx.add_submenu(
"OPS",
"Operations",
ops,
aliases=["OPERATIONS"],
)
return flx
def test_suggest_namespace_entries_root(falyx):
completer = FalyxCompleter(falyx)
completions = completer._suggest_namespace_entries(falyx, "R")
assert "R" in completions
assert "RUN" in completions
completions = completer._suggest_namespace_entries(falyx, "r")
assert "r" in completions
assert "run" in completions
def test_suggest_namespace_entries_submenu(falyx):
completer = FalyxCompleter(falyx)
ops = falyx.namespaces["OPS"].namespace
completions = completer._suggest_namespace_entries(ops, "D")
assert "D" in completions
assert "DEPLOY" in completions
def test_get_completions_no_input_shows_root_entries(falyx):
completer = FalyxCompleter(falyx)
results = list(completer.get_completions(Document(""), None))
texts = completion_texts(results)
assert any(isinstance(c, Completion) for c in results)
assert "R" in texts
assert "OPS" in texts
assert "X" in texts
def test_get_completions_partial_root_entry(falyx):
completer = FalyxCompleter(falyx)
results = list(completer.get_completions(Document("OP"), None))
texts = completion_texts(results)
assert "OPS" in texts
assert "OPERATIONS" in texts
def test_get_completions_no_match_returns_empty(falyx):
completer = FalyxCompleter(falyx)
assert list(completer.get_completions(Document("Z"), None)) == []
assert list(completer.get_completions(Document("OPS Z"), None)) == []
def test_get_completions_namespace_boundary_suggests_help_flags(falyx):
completer = FalyxCompleter(falyx)
results = list(completer.get_completions(Document("OPS -"), None))
texts = completion_texts(results)
assert "-h" in texts
assert "--help" in texts
assert "-T" not in texts
assert "--tldr" not in texts
falyx.add_tldr_example(
entry_key="R",
usage="",
description="This is a TLDR example for the R command.",
)
results = list(completer.get_completions(Document("-"), None))
texts = completion_texts(results)
assert "-h" in texts
assert "--help" in texts
assert "-T" in texts
assert "--tldr" in texts
def test_get_completions_preview_prefix_is_preserved(falyx):
completer = FalyxCompleter(falyx)
results = list(completer.get_completions(Document("?R"), None))
texts = completion_texts(results)
assert any(text.startswith("?R") for text in texts)
def test_get_completions_preview_prefix_for_namespace_entries(falyx):
completer = FalyxCompleter(falyx)
results = list(completer.get_completions(Document("?OP"), None))
texts = completion_texts(results)
assert "?OPS" in texts or "?OPERATIONS" in texts
def test_get_completions_leaf_command_delegates_flags_to_root_command_parser(
falyx, monkeypatch
):
completer = FalyxCompleter(falyx)
seen = {}
def fake_suggest_next(args, cursor_at_end_of_token):
seen["args"] = list(args)
seen["cursor_at_end_of_token"] = cursor_at_end_of_token
return ["--tag"]
monkeypatch.setattr(
falyx.commands["R"].arg_parser,
"suggest_next",
fake_suggest_next,
)
results = list(completer.get_completions(Document("R --t"), None))
texts = completion_texts(results)
assert seen["args"] == ["--t"]
assert seen["cursor_at_end_of_token"] is False
assert "--tag" in texts
def test_get_completions_leaf_command_delegates_flags_to_submenu_command_parser(
falyx, monkeypatch
):
completer = FalyxCompleter(falyx)
ops = falyx.namespaces["OPS"].namespace
deploy = ops.commands["D"]
seen = {}
def fake_suggest_next(args, cursor_at_end_of_token):
seen["args"] = list(args)
seen["cursor_at_end_of_token"] = cursor_at_end_of_token
return ["--target"]
monkeypatch.setattr(
deploy.arg_parser,
"suggest_next",
fake_suggest_next,
)
results = list(completer.get_completions(Document("OPS D --t"), None))
texts = completion_texts(results)
assert seen["args"] == ["--t"]
assert seen["cursor_at_end_of_token"] is False
assert "--target" in texts
def test_get_completions_leaf_command_receives_empty_stub_after_space(falyx, monkeypatch):
completer = FalyxCompleter(falyx)
seen = {}
def fake_suggest_next(args, cursor_at_end_of_token):
seen["args"] = list(args)
seen["cursor_at_end_of_token"] = cursor_at_end_of_token
return ["--tag", "--name"]
monkeypatch.setattr(
falyx.commands["R"].arg_parser,
"suggest_next",
fake_suggest_next,
)
results = list(completer.get_completions(Document("R "), None))
texts = completion_texts(results)
assert seen["args"] == []
assert seen["cursor_at_end_of_token"] is True
assert "--tag" in texts
assert "--name" in texts
def test_get_completions_bad_input(falyx):
completer = FalyxCompleter(falyx)
results = list(completer.get_completions(Document('R "unclosed quote'), None))
assert results == []
def test_get_completions_exception_handling(falyx, monkeypatch):
completer = FalyxCompleter(falyx)
def boom(*args, **kwargs):
raise ZeroDivisionError("boom")
monkeypatch.setattr(falyx.commands["R"].arg_parser, "suggest_next", boom)
results = list(completer.get_completions(Document("R --tag"), None))
assert results == []
def test_ensure_quote_wraps_whitespace(falyx):
completer = FalyxCompleter(falyx)
assert completer._ensure_quote("hello world") == '"hello world"'
assert completer._ensure_quote("hello") == "hello"
def test_command_suggestions_are_case_insensitive(falyx):
completer = FalyxCompleter(falyx)
results = list(completer.get_completions(Document("r"), None))
texts = completion_texts(results)
assert "r" in texts
assert "run" in texts
results = list(completer.get_completions(Document("R"), None))
texts = completion_texts(results)
assert "R" in texts
assert "RUN" in texts
def test_namespace_suggestions_are_case_insensitive(falyx):
completer = FalyxCompleter(falyx)
results = list(completer.get_completions(Document("op"), None))
texts = completion_texts(results)
assert "ops" in texts
assert "operations" in texts
results = list(completer.get_completions(Document("OP"), None))
texts = completion_texts(results)
assert "OPS" in texts
assert "OPERATIONS" in texts
def test_command_completions_after_namespace(falyx):
completer = FalyxCompleter(falyx)
results = list(completer.get_completions(Document("OPS D --"), None))
texts = completion_texts(results)
assert "--target" in texts
assert "--region" in texts
assert "--help" in texts

View File

@@ -0,0 +1,42 @@
from types import SimpleNamespace
import pytest
from falyx.completer import FalyxCompleter
def completion_texts(completions) -> list[str]:
return [c.text for c in completions]
def test_lcp_completions():
completer = FalyxCompleter(SimpleNamespace())
suggestions = ["AETHERWARP", "AETHERZOOM"]
stub = "A"
completions = list(completer._yield_lcp_completions(suggestions, stub))
texts = completion_texts(completions)
assert "AETHER" in texts
assert "AETHERWARP" in texts
assert "AETHERZOOM" in texts
def test_lcp_completions_space():
completer = FalyxCompleter(SimpleNamespace())
suggestions = ["London", "New York", "San Francisco"]
stub = "N"
completions = list(completer._yield_lcp_completions(suggestions, stub))
texts = completion_texts(completions)
assert '"New York"' in texts
def test_lcp_completions_does_not_collapse_flags():
completer = FalyxCompleter(SimpleNamespace())
suggestions = ["--tag", "--target"]
stub = "--t"
completions = list(completer._yield_lcp_completions(suggestions, stub))
texts = completion_texts(completions)
assert "--tag" in texts
assert "--target" in texts
assert "--ta" not in texts

341
tests/test_context.py Normal file
View File

@@ -0,0 +1,341 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import pytest
from rich.console import Console
from falyx.context import ExecutionContext, InvocationContext, SharedContext
from falyx.mode import FalyxMode
class DummyAction:
def __init__(self, name: str = "DummyAction") -> None:
self.name = name
def __str__(self) -> str:
return self.name
def make_execution_context(**overrides: Any) -> ExecutionContext:
defaults: dict[str, Any] = {
"name": "Build",
"action": DummyAction("build"),
}
defaults.update(overrides)
return ExecutionContext(**defaults)
def make_shared_context(**overrides: Any) -> SharedContext:
defaults: dict[str, Any] = {
"name": "Workflow",
"action": DummyAction("workflow"),
}
defaults.update(overrides)
return SharedContext(**defaults)
def test_execution_context_get_shared_context_returns_existing_context() -> None:
shared = make_shared_context()
context = make_execution_context(shared_context=shared)
assert context.get_shared_context() is shared
def test_execution_context_get_shared_context_raises_when_missing() -> None:
context = make_execution_context()
with pytest.raises(ValueError, match="SharedContext is not set"):
context.get_shared_context()
def test_execution_context_duration_handles_not_started_running_and_stopped(
monkeypatch: pytest.MonkeyPatch,
) -> None:
context = make_execution_context()
assert context.duration is None
context.start_time = 10.0
context.end_time = None
monkeypatch.setattr("falyx.context.time.perf_counter", lambda: 12.5)
assert context.duration == pytest.approx(2.5)
context.end_time = 14.0
assert context.duration == pytest.approx(4.0)
def test_execution_context_start_and_stop_timer_populate_timer_fields() -> None:
context = make_execution_context()
context.start_timer()
assert context.start_wall is not None
assert context.start_time is not None
context.stop_timer()
assert context.end_wall is not None
assert context.end_time is not None
assert context.duration is not None
assert context.duration >= 0
def test_execution_context_exception_setter_records_traceback_and_status() -> None:
context = make_execution_context(result="ignored after failure")
context.exception = RuntimeError("boom")
assert isinstance(context.exception, RuntimeError)
assert context.success is False
assert context.status == "ERROR"
assert context.traceback is not None
assert "RuntimeError: boom" in context.traceback
def test_execution_context_as_dict_includes_result_exception_traceback_duration_and_extra() -> (
None
):
context = make_execution_context(
result={"artifact": "dist/app.whl"},
start_time=2.0,
end_time=5.25,
extra={"attempt": 2},
)
context.exception = ValueError("invalid build")
summary = context.as_dict()
assert summary["name"] == "Build"
assert summary["result"] == {"artifact": "dist/app.whl"}
assert summary["exception"] == "ValueError('invalid build')"
assert "ValueError: invalid build" in summary["traceback"]
assert summary["duration"] == pytest.approx(3.25)
assert summary["extra"] == {"attempt": 2}
def test_execution_context_signature_formats_args_and_kwargs() -> None:
context = make_execution_context(args=("src", 3), kwargs={"verbose": True})
assert context.signature == "build ('src', 3, verbose=True)"
def test_execution_context_log_summary_prints_success_to_context_console() -> None:
recording_console = Console(record=True, width=160)
context = make_execution_context(
result="ok",
start_time=1.0,
end_time=2.5,
start_wall=datetime(2026, 6, 7, 11, 0, 0),
end_wall=datetime(2026, 6, 7, 11, 0, 2),
console=recording_console,
)
context.log_summary()
output = recording_console.export_text()
assert "[SUMMARY] Build" in output
assert "Start: 11:00:00" in output
assert "End: 11:00:02" in output
assert "Duration: 1.500s" in output
assert "Result: ok" in output
def test_execution_context_log_summary_uses_logger_and_includes_exception() -> None:
messages: list[str] = []
context = make_execution_context(
result="unused",
start_time=10.0,
end_time=11.0,
)
context.exception = OSError("disk full")
context.log_summary(logger=messages.append)
assert len(messages) == 1
assert "[SUMMARY] Build" in messages[0]
assert "Duration: 1.000s" in messages[0]
assert "Exception: OSError('disk full')" in messages[0]
def test_execution_context_to_log_line_renders_success_and_error_states() -> None:
success = make_execution_context(result="ok", start_time=1.0, end_time=1.5)
failure = make_execution_context(result=None, start_time=2.0, end_time=3.0)
failure.exception = LookupError("missing")
assert success.to_log_line() == (
"[Build] status=OK duration=0.500s result='ok' exception=None"
)
assert failure.to_log_line() == (
"[Build] status=ERROR duration=1.000s result=None "
"exception=LookupError: missing"
)
def test_execution_context_str_and_repr_render_success_with_no_duration() -> None:
context = make_execution_context(result=["ok"])
text = str(context)
debug = repr(context)
assert "<ExecutionContext 'Build' | OK | Duration: n/a" in text
assert "Result: ['ok']" in text
assert "ExecutionContext(name='Build', duration=n/a" in debug
assert "result=['ok']" in debug
def test_execution_context_str_and_repr_render_exception_with_duration() -> None:
context = make_execution_context(start_time=1.0, end_time=1.75)
context.exception = RuntimeError("failed")
text = str(context)
debug = repr(context)
assert "<ExecutionContext 'Build' | ERROR | Duration: 0.750s" in text
assert "Exception: failed" in text
assert "duration=0.750" in debug
assert "exception=RuntimeError('failed')" in debug
def test_shared_context_records_results_errors_and_share_values() -> None:
shared = make_shared_context()
error = RuntimeError("step failed")
shared.add_result("first")
shared.add_error(1, error)
shared.set("artifact", "dist/app.whl")
assert shared.results == ["first"]
assert shared.errors == [(1, error)]
assert shared.get("artifact") == "dist/app.whl"
assert shared.get("missing", "default") == "default"
assert shared.last_result() == "first"
def test_shared_context_last_result_returns_none_when_sequential_context_has_no_results() -> (
None
):
shared = make_shared_context()
assert shared.last_result() is None
def test_shared_context_set_shared_result_does_not_append_for_sequential_context() -> (
None
):
shared = make_shared_context(is_concurrent=False)
shared.set_shared_result("shared-value")
assert shared.shared_result == "shared-value"
assert shared.results == []
assert shared.last_result() is None
def test_shared_context_set_shared_result_appends_and_reads_from_concurrent_context() -> (
None
):
shared = make_shared_context(is_concurrent=True)
shared.set_shared_result("group-value")
assert shared.shared_result == "group-value"
assert shared.results == ["group-value"]
assert shared.last_result() == "group-value"
def test_shared_context_str_marks_sequential_and_concurrent_modes() -> None:
sequential = make_shared_context(results=["a"])
concurrent = make_shared_context(is_concurrent=True, results=["b"])
assert "<SequentialSharedContext 'Workflow'" in str(sequential)
assert "Results: ['a']" in str(sequential)
assert "<ConcurrentSharedContext 'Workflow'" in str(concurrent)
assert "Results: ['b']" in str(concurrent)
def test_invocation_context_menu_path_segment_operations_are_immutable() -> None:
root = InvocationContext(program="falyx", mode=FalyxMode.MENU)
one = root.with_path_segment("admin", style="cyan")
two = one.with_path_segment("deploy", style="green")
trimmed = two.without_last_path_segment()
assert root.typed_path == []
assert root.segments == []
assert one.typed_path == ["admin"]
assert one.segments[0].text == "admin"
assert str(one.segments[0].style) == "cyan"
assert two.typed_path == ["admin", "deploy"]
assert trimmed.typed_path == ["admin"]
assert trimmed.segments[0].text == "admin"
assert root.without_last_path_segment() is root
def test_invocation_context_plain_path_omits_program_in_menu_mode() -> None:
context = (
InvocationContext(program="falyx", mode=FalyxMode.MENU)
.with_path_segment("admin")
.with_path_segment("deploy")
)
assert context.is_cli_mode is False
assert context.plain_path == "admin deploy"
def test_invocation_context_plain_path_includes_program_in_cli_mode() -> None:
context = (
InvocationContext(program="falyx", mode=FalyxMode.COMMAND)
.with_path_segment("admin")
.with_path_segment("deploy")
)
assert context.is_cli_mode is True
assert context.plain_path == "falyx admin deploy"
def test_invocation_context_plain_path_handles_cli_context_without_program() -> None:
context = InvocationContext(mode=FalyxMode.COMMAND).with_path_segment("deploy")
assert context.plain_path == "deploy"
def test_invocation_context_markup_path_styles_program_and_segments_and_escapes_text() -> (
None
):
context = (
InvocationContext(
program="falyx[dev]",
program_style="bold blue",
mode=FalyxMode.COMMAND,
)
.with_path_segment("admin[ops]", style="cyan")
.with_path_segment("deploy", style="green")
)
assert context.markup_path == (
"[bold blue]falyx\\[dev][/bold blue] "
"[cyan]admin\\[ops][/cyan] "
"[green]deploy[/green]"
)
def test_invocation_context_markup_path_handles_unstyled_program_and_segments() -> None:
context = (
InvocationContext(program="falyx", mode=FalyxMode.COMMAND)
.with_path_segment("admin[ops]")
.with_path_segment("deploy")
)
assert context.markup_path == "falyx admin\\[ops] deploy"
def test_invocation_context_markup_path_omits_program_in_menu_mode() -> None:
context = (
InvocationContext(
program="falyx",
program_style="bold blue",
mode=FalyxMode.MENU,
)
.with_path_segment("admin", style="cyan")
.with_path_segment("deploy")
)
assert context.markup_path == "[cyan]admin[/cyan] deploy"

View File

@@ -0,0 +1,30 @@
import pytest
from falyx.execution_option import ExecutionOption
def test_execution_option_accepts_valid_string_values():
assert ExecutionOption("summary") == ExecutionOption.SUMMARY
assert ExecutionOption("retry") == ExecutionOption.RETRY
assert ExecutionOption("confirm") == ExecutionOption.CONFIRM
def test_execution_option_rejects_invalid_string():
with pytest.raises(ValueError, match="Invalid ExecutionOption: 'invalid'"):
ExecutionOption("invalid")
def test_execution_option_normalizes_case_and_whitespace():
assert ExecutionOption(" SUMMARY ") == ExecutionOption.SUMMARY
assert ExecutionOption("ReTrY") == ExecutionOption.RETRY
assert ExecutionOption("\tconfirm\n") == ExecutionOption.CONFIRM
def test_execution_option_rejects_non_string():
with pytest.raises(ValueError, match="Invalid ExecutionOption: 123"):
ExecutionOption(123)
def test_execution_option_error_lists_valid_values():
with pytest.raises(ValueError, match="Must be one of: summary, retry, confirm"):
ExecutionOption("invalid")

View File

@@ -0,0 +1,307 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Iterator
import pytest
from rich.console import Console
from rich.table import Table
from falyx.execution_registry import ExecutionRegistry
@dataclass
class DummyAction:
ignore_in_history: bool = False
class DummyContext:
def __init__(
self,
name: str,
*,
result: Any = None,
exception: Exception | None = None,
traceback: str = "",
signature: str | None = None,
start_time: float | None = 1_700_000_000.0,
end_time: float | None = 1_700_000_001.0,
duration: float | None = 1.25,
ignore_in_history: bool = False,
) -> None:
self.index = -1
self.name = name
self.result = result
self.exception = exception
self.traceback = traceback
self.signature = signature or f"{name}()"
self.start_time = start_time
self.end_time = end_time
self.duration = duration
self.action = DummyAction(ignore_in_history=ignore_in_history)
self.success = exception is None
def to_log_line(self) -> str:
return f"log:{self.name}:{self.index}"
class CaptureConsole:
def __init__(self) -> None:
self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
def print(self, *args: Any, **kwargs: Any) -> None:
self.printed.append((args, kwargs))
def rendered_text(self) -> str:
output = Console(record=True, width=160)
for args, kwargs in self.printed:
output.print(*args, **kwargs)
return output.export_text()
@pytest.fixture(autouse=True)
def isolated_registry() -> Iterator[CaptureConsole]:
original_console = ExecutionRegistry._console
capture = CaptureConsole()
ExecutionRegistry._console = capture # type: ignore[assignment]
ExecutionRegistry._store_by_name.clear()
ExecutionRegistry._store_by_index.clear()
ExecutionRegistry._store_all.clear()
ExecutionRegistry._index = 0
yield capture
ExecutionRegistry._store_by_name.clear()
ExecutionRegistry._store_by_index.clear()
ExecutionRegistry._store_all.clear()
ExecutionRegistry._index = 0
ExecutionRegistry._console = original_console
def record_context(*args: Any, **kwargs: Any) -> DummyContext:
context = DummyContext(*args, **kwargs)
ExecutionRegistry.record(context) # type: ignore[arg-type]
return context
def latest_printed_table(console: CaptureConsole) -> Table:
assert console.printed
table = console.printed[-1][0][0]
assert isinstance(table, Table)
return table
def test_record_assigns_indexes_and_populates_all_lookup_stores() -> None:
first = record_context("Build", result="ok")
second = record_context("Build", result="again")
other = record_context("Deploy", result="done")
assert first.index == 0
assert second.index == 1
assert other.index == 2
assert ExecutionRegistry.get_all() == [first, second, other]
assert ExecutionRegistry.get_by_name("Build") == [first, second]
assert ExecutionRegistry.get_by_name("missing") == []
assert ExecutionRegistry._store_by_index == {0: first, 1: second, 2: other}
assert ExecutionRegistry.get_latest() is other
def test_clear_removes_all_recorded_contexts() -> None:
record_context("Build", result="ok")
ExecutionRegistry.clear()
assert ExecutionRegistry.get_all() == []
assert ExecutionRegistry.get_by_name("Build") == []
assert ExecutionRegistry._store_by_index == {}
def test_summary_clear_clears_registry_and_prints_confirmation(
isolated_registry: CaptureConsole,
) -> None:
record_context("Build", result="ok")
ExecutionRegistry.summary(clear=True)
assert ExecutionRegistry.get_all() == []
assert "Execution history cleared" in isolated_registry.rendered_text()
def test_summary_last_result_skips_ignored_contexts(
isolated_registry: CaptureConsole,
) -> None:
visible = record_context("Visible", result={"answer": 42})
record_context("Ignored", result="do not show", ignore_in_history=True)
ExecutionRegistry.summary(last_result=True)
assert isolated_registry.printed[0][0] == (f"{visible.signature}:",)
assert isolated_registry.printed[1][0] == (visible.result,)
def test_summary_last_result_prints_traceback_when_latest_visible_context_failed(
isolated_registry: CaptureConsole,
) -> None:
failed = record_context("Fail", exception=RuntimeError("boom"), traceback="TRACEBACK")
ExecutionRegistry.summary(last_result=True)
assert isolated_registry.printed[0][0] == (f"{failed.signature}:",)
assert isolated_registry.printed[1][0] == ("TRACEBACK",)
def test_summary_last_result_reports_when_all_contexts_are_ignored(
isolated_registry: CaptureConsole,
) -> None:
record_context("Ignored", result="hidden", ignore_in_history=True)
ExecutionRegistry.summary(last_result=True)
assert "No valid executions found" in isolated_registry.rendered_text()
def test_summary_result_index_prints_result_for_existing_context(
isolated_registry: CaptureConsole,
) -> None:
context = record_context("Build", result=["artifact.whl"])
ExecutionRegistry.summary(result_index=context.index)
assert isolated_registry.printed[0][0] == (f"{context.signature}:",)
assert isolated_registry.printed[1][0] == (context.result,)
def test_summary_result_index_prints_traceback_for_failed_context(
isolated_registry: CaptureConsole,
) -> None:
context = record_context("Fail", exception=ValueError("bad"), traceback="STACK")
ExecutionRegistry.summary(result_index=context.index)
assert isolated_registry.printed[0][0] == (f"{context.signature}:",)
assert isolated_registry.printed[1][0] == ("STACK",)
def test_summary_result_index_reports_missing_index(
isolated_registry: CaptureConsole,
) -> None:
ExecutionRegistry.summary(result_index=99)
assert "No execution found for index 99" in isolated_registry.rendered_text()
def test_summary_name_filter_reports_missing_action(
isolated_registry: CaptureConsole,
) -> None:
record_context("Build", result="ok")
ExecutionRegistry.summary(name="Deploy")
assert "No executions found for action 'Deploy'" in isolated_registry.rendered_text()
def test_summary_name_filter_renders_only_matching_contexts(
isolated_registry: CaptureConsole,
) -> None:
record_context("Build", result="ok")
record_context("Deploy", result="done")
record_context("Build", result="again")
ExecutionRegistry.summary(name="Build")
table = latest_printed_table(isolated_registry)
assert table.title == "📊 Execution History for 'Build'"
assert len(table.rows) == 2
rendered = isolated_registry.rendered_text()
assert "Build" in rendered
assert "Deploy" not in rendered
def test_summary_index_filter_renders_existing_context(
isolated_registry: CaptureConsole,
capsys: pytest.CaptureFixture[str],
) -> None:
first = record_context("Build", result="ok")
second = record_context("Deploy", result="done")
ExecutionRegistry.summary(index=second.index)
table = latest_printed_table(isolated_registry)
assert table.title == f"📊 Execution History for Index {second.index}"
assert len(table.rows) == 1
rendered = isolated_registry.rendered_text()
assert "Deploy" in rendered
assert "Build" not in rendered
# The implementation currently prints the filtered context list directly.
assert str([second]) in capsys.readouterr().out
assert first.index == 0
def test_summary_index_filter_reports_missing_index(
isolated_registry: CaptureConsole,
) -> None:
ExecutionRegistry.summary(index=12)
assert "No execution found for index 12" in isolated_registry.rendered_text()
def test_summary_status_success_filters_out_errors_and_truncates_long_results(
isolated_registry: CaptureConsole,
) -> None:
long_result = "x" * 80
record_context("Success", result=long_result)
record_context("Failure", exception=RuntimeError("boom"))
ExecutionRegistry.summary(status="success")
table = latest_printed_table(isolated_registry)
assert len(table.rows) == 1
rendered = isolated_registry.rendered_text()
assert "Success" in rendered
assert "Failure" not in rendered
assert "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..." in rendered
def test_summary_status_error_filters_out_successes(
isolated_registry: CaptureConsole,
) -> None:
record_context("Success", result="ok")
record_context("Failure", exception=RuntimeError("boom"))
ExecutionRegistry.summary(status="error")
table = latest_printed_table(isolated_registry)
assert len(table.rows) == 1
rendered = isolated_registry.rendered_text()
assert "Failure" in rendered
assert "RuntimeError" in rendered
assert "Success" not in rendered
def test_summary_uses_na_for_missing_timestamps_and_duration(
isolated_registry: CaptureConsole,
) -> None:
record_context("Pending", result=None, start_time=None, end_time=None, duration=None)
ExecutionRegistry.summary()
rendered = isolated_registry.rendered_text()
assert "Pending" in rendered
assert "n/a" in rendered
def test_summary_defaults_to_all_contexts(
isolated_registry: CaptureConsole,
) -> None:
record_context("One", result="ok")
record_context("Two", exception=RuntimeError("boom"))
ExecutionRegistry.summary()
table = latest_printed_table(isolated_registry)
assert table.title == "📊 Execution History"
assert len(table.rows) == 2
rendered = isolated_registry.rendered_text()
assert "One" in rendered
assert "Two" in rendered

View File

@@ -0,0 +1,55 @@
import logging
from falyx import Falyx
from falyx.action import Action
from falyx.debug import log_after, log_before, log_error, log_success
from falyx.hook_manager import HookType
def test_apply_root_options_sets_falyx_logger_level_from_root_verbose():
flx = Falyx()
falyx_logger = logging.getLogger("falyx")
original_level = falyx_logger.level
try:
flx.options_manager.set("verbose", True, "root")
flx._apply_root_options()
assert falyx_logger.level == logging.DEBUG
flx.options_manager.set("verbose", False, "root")
flx._apply_root_options()
assert falyx_logger.level == logging.WARNING
finally:
falyx_logger.setLevel(original_level)
def test_apply_root_options_registers_debug_hooks_across_command_and_action_graph():
action = Action("deploy-action", lambda: "ok")
flx = Falyx()
command = flx.add_command(
key="D",
description="Deploy",
action=action,
)
assert flx.hooks._hooks[HookType.BEFORE] == []
assert command.hooks._hooks[HookType.BEFORE] == []
assert action.hooks._hooks[HookType.BEFORE] == []
flx.options_manager.set("debug_hooks", True, "root")
flx._apply_root_options()
assert flx.hooks._hooks[HookType.BEFORE] == [log_before]
assert flx.hooks._hooks[HookType.ON_SUCCESS] == [log_success]
assert flx.hooks._hooks[HookType.ON_ERROR] == [log_error]
assert flx.hooks._hooks[HookType.AFTER] == [log_after]
assert command.hooks._hooks[HookType.BEFORE] == [log_before]
assert command.hooks._hooks[HookType.ON_SUCCESS] == [log_success]
assert command.hooks._hooks[HookType.ON_ERROR] == [log_error]
assert command.hooks._hooks[HookType.AFTER] == [log_after]
assert action.hooks._hooks[HookType.BEFORE] == [log_before]
assert action.hooks._hooks[HookType.ON_SUCCESS] == [log_success]
assert action.hooks._hooks[HookType.ON_ERROR] == [log_error]
assert action.hooks._hooks[HookType.AFTER] == [log_after]

View File

@@ -0,0 +1,138 @@
import pytest
from falyx import Falyx
from falyx.action import Action, ChainedAction
from falyx.command import Command
from falyx.options_manager import OptionsManager
from falyx.parser import CommandArgumentParser
def test_add_command_from_command_returns_bound_clone():
source = Falyx(program="source")
target = Falyx(program="target")
original = source.add_command(
"D",
"Deploy",
action=lambda: "ok",
aliases=["deploy"],
help_text="Deploy something.",
)
bound = target.add_command_from_command(original)
assert bound is target.commands["D"]
assert bound is not original
assert bound.key == original.key
assert bound.description == original.description
assert bound.aliases == original.aliases
assert bound.program == target.program
def test_add_command_from_command_does_not_reuse_original_options_manager():
source = Falyx(program="source")
target = Falyx(program="target")
original = source.add_command("D", "Deploy", action=lambda: "ok")
bound = target.add_command_from_command(original)
assert original.options_manager is source.options_manager
assert bound.options_manager is target.options_manager
assert bound.options_manager is not original.options_manager
def test_add_command_from_command_returns_isolated_clone():
flx1 = Falyx(program="one")
flx2 = Falyx(program="two")
original = flx1.add_command("D", "Deploy", action=Action("deploy", lambda: "ok"))
bound = flx2.add_command_from_command(original)
assert bound is not original
assert bound.options_manager is flx2.options_manager
assert original.options_manager is flx1.options_manager
if bound.arg_parser and original.arg_parser:
assert bound.arg_parser is not original.arg_parser
assert bound.arg_parser.options_manager is flx2.options_manager
assert original.arg_parser.options_manager is flx1.options_manager
assert bound.action is not original.action
def test_clone_with_overrides_clones_arg_parser_and_base_action_graph():
original_options = OptionsManager()
cloned_options = OptionsManager()
parser = CommandArgumentParser(
command_key="D",
command_description="Deploy",
options_manager=original_options,
)
parser.add_argument("--region", default="us-east")
action = ChainedAction(
name="deploy-flow",
actions=[
Action("step-one", lambda: "one"),
Action("step-two", lambda: "two"),
],
)
command = Command.build(
key="D",
description="Deploy",
action=action,
arg_parser=parser,
options_manager=original_options,
program="source",
)
cloned = command.clone_with_overrides(
options_manager=cloned_options,
program="target",
)
assert cloned is not command
assert cloned.program == "target"
assert cloned.options_manager is cloned_options
assert command.options_manager is original_options
assert cloned.arg_parser is not command.arg_parser
assert cloned.arg_parser.options_manager is cloned_options
assert command.arg_parser.options_manager is original_options
assert cloned.action is not command.action
assert isinstance(cloned.action, ChainedAction)
assert isinstance(command.action, ChainedAction)
assert cloned.action.actions is not command.action.actions
assert len(cloned.action.actions) == len(command.action.actions)
for cloned_child, original_child in zip(
cloned.action.actions,
command.action.actions,
strict=True,
):
assert cloned_child is not original_child
assert cloned_child.name == original_child.name
cloned.arg_parser.add_argument("--profile", default="dev")
assert command.arg_parser.get_argument("profile") is None
def test_clone_with_overrides_preserves_boolean_contract_flags():
command = Command.build(
"H",
"Hidden-ish helper",
lambda: None,
auto_args=False,
simple_help_signature=True,
ignore_in_history=True,
)
cloned = command.clone_with_overrides()
assert cloned.auto_args is False
assert cloned.simple_help_signature is True
assert cloned.ignore_in_history is True

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