Compare commits
6 Commits
main
...
falyx-pars
| Author | SHA1 | Date | |
|---|---|---|---|
|
8db7a9e6dc
|
|||
|
cce92cca09
|
|||
|
dcec792d32
|
|||
|
8ece2a5de6
|
|||
|
30cb8b97b5
|
|||
|
5d8f3aa603
|
@@ -1,7 +1,6 @@
|
|||||||
"""
|
"""Falyx CLI Framework
|
||||||
Falyx CLI Framework
|
|
||||||
|
|
||||||
Copyright (c) 2025 rtj.dev LLC.
|
Copyright (c) 2026 rtj.dev LLC.
|
||||||
Licensed under the MIT License. See LICENSE file for details.
|
Licensed under the MIT License. See LICENSE file for details.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
"""
|
"""Falyx CLI Framework
|
||||||
Falyx CLI Framework
|
|
||||||
|
|
||||||
Copyright (c) 2025 rtj.dev LLC.
|
Copyright (c) 2026 rtj.dev LLC.
|
||||||
Licensed under the MIT License. See LICENSE file for details.
|
Licensed under the MIT License. See LICENSE file for details.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from argparse import ArgumentParser, Namespace, _SubParsersAction
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from falyx.config import loader
|
from falyx.config import loader
|
||||||
from falyx.falyx import Falyx
|
from falyx.falyx import Falyx
|
||||||
from falyx.parser import CommandArgumentParser, get_root_parser, get_subparsers
|
from falyx.parser import CommandArgumentParser
|
||||||
|
|
||||||
|
|
||||||
def find_falyx_config() -> Path | None:
|
def find_falyx_config() -> Path | None:
|
||||||
@@ -49,48 +47,11 @@ def init_config(parser: CommandArgumentParser) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def init_callback(args: Namespace) -> None:
|
def build_bootstrap_falyx() -> Falyx:
|
||||||
"""Callback for the init command."""
|
|
||||||
if args.command == "init":
|
|
||||||
from falyx.init import init_project
|
|
||||||
|
|
||||||
init_project(args.name)
|
|
||||||
elif args.command == "init_global":
|
|
||||||
from falyx.init import init_global
|
|
||||||
|
|
||||||
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.",
|
|
||||||
)
|
|
||||||
init_parser.add_argument(
|
|
||||||
"name",
|
|
||||||
type=str,
|
|
||||||
help="Name of the new Falyx project",
|
|
||||||
default=".",
|
|
||||||
nargs="?",
|
|
||||||
)
|
|
||||||
subparsers.add_parser(
|
|
||||||
"init-global",
|
|
||||||
help="Initialize Falyx global configuration",
|
|
||||||
description="Create a global Falyx configuration at ~/.config/falyx/.",
|
|
||||||
)
|
|
||||||
return root_parser, subparsers
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> Any:
|
|
||||||
bootstrap_path = bootstrap()
|
|
||||||
if not bootstrap_path:
|
|
||||||
from falyx.init import init_global, init_project
|
from falyx.init import init_global, init_project
|
||||||
|
|
||||||
flx: Falyx = Falyx()
|
flx = Falyx()
|
||||||
|
|
||||||
flx.add_command(
|
flx.add_command(
|
||||||
"I",
|
"I",
|
||||||
"Initialize a new Falyx project",
|
"Initialize a new Falyx project",
|
||||||
@@ -106,14 +67,19 @@ def main() -> Any:
|
|||||||
aliases=["init-global"],
|
aliases=["init-global"],
|
||||||
help_text="Create a global Falyx configuration at ~/.config/falyx/.",
|
help_text="Create a global Falyx configuration at ~/.config/falyx/.",
|
||||||
)
|
)
|
||||||
else:
|
return flx
|
||||||
flx = loader(bootstrap_path)
|
|
||||||
|
|
||||||
root_parser, subparsers = get_parsers()
|
|
||||||
|
|
||||||
return asyncio.run(
|
def build_falyx() -> Falyx:
|
||||||
flx.run(root_parser=root_parser, subparsers=subparsers, callback=init_callback)
|
bootstrap_path = bootstrap()
|
||||||
)
|
if bootstrap_path:
|
||||||
|
return loader(bootstrap_path)
|
||||||
|
return build_bootstrap_falyx()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> Any:
|
||||||
|
flx = build_falyx()
|
||||||
|
return asyncio.run(flx.run())
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""
|
"""Falyx CLI Framework
|
||||||
Falyx CLI Framework
|
|
||||||
|
|
||||||
Copyright (c) 2025 rtj.dev LLC.
|
Copyright (c) 2026 rtj.dev LLC.
|
||||||
Licensed under the MIT License. See LICENSE file for details.
|
Licensed under the MIT License. See LICENSE file for details.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# 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
|
||||||
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.
|
execute a single callable or coroutine with structured lifecycle support.
|
||||||
|
|
||||||
An `Action` is the simplest building block in Falyx's execution model, enabling
|
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):
|
class Action(BaseAction):
|
||||||
"""
|
"""Action wraps a simple function or coroutine into a standard executable unit.
|
||||||
Action wraps a simple function or coroutine into a standard executable unit.
|
|
||||||
|
|
||||||
It supports:
|
It supports:
|
||||||
- Optional retry logic.
|
- Optional retry logic.
|
||||||
@@ -148,8 +146,8 @@ class Action(BaseAction):
|
|||||||
self.enable_retry()
|
self.enable_retry()
|
||||||
|
|
||||||
def get_infer_target(self) -> tuple[Callable[..., Any], None]:
|
def get_infer_target(self) -> tuple[Callable[..., Any], None]:
|
||||||
"""
|
"""Returns the callable to be used for argument inference.
|
||||||
Returns the callable to be used for argument inference.
|
|
||||||
By default, it returns the action itself.
|
By default, it returns the action itself.
|
||||||
"""
|
"""
|
||||||
return self.action, None
|
return self.action, None
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Defines `ActionFactory`, a dynamic Falyx Action that defers the construction of its
|
||||||
Defines `ActionFactory`, a dynamic Falyx Action that defers the construction of its
|
|
||||||
underlying logic to runtime using a user-defined factory function.
|
underlying logic to runtime using a user-defined factory function.
|
||||||
|
|
||||||
This pattern is useful when the specific Action to execute cannot be determined until
|
This pattern is useful when the specific Action to execute cannot be determined until
|
||||||
@@ -46,8 +45,7 @@ from falyx.utils import ensure_async
|
|||||||
|
|
||||||
|
|
||||||
class ActionFactory(BaseAction):
|
class ActionFactory(BaseAction):
|
||||||
"""
|
"""Dynamically creates and runs another Action at runtime using a factory function.
|
||||||
Dynamically creates and runs another Action at runtime using a factory function.
|
|
||||||
|
|
||||||
This is useful for generating context-specific behavior (e.g., dynamic HTTPActions)
|
This is useful for generating context-specific behavior (e.g., dynamic HTTPActions)
|
||||||
where the structure of the next action depends on runtime values.
|
where the structure of the next action depends on runtime values.
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Defines `ActionGroup`, a Falyx Action that executes multiple sub-actions concurrently
|
||||||
Defines `ActionGroup`, a Falyx Action that executes multiple sub-actions concurrently
|
using asynchronous concurrency.
|
||||||
using asynchronous parallelism.
|
|
||||||
|
|
||||||
`ActionGroup` is designed for workflows where several independent actions can run
|
`ActionGroup` is designed for workflows where several independent actions can run
|
||||||
simultaneously to improve responsiveness and reduce latency. It ensures robust error
|
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.
|
Falyx's introspectability and chaining capabilities.
|
||||||
|
|
||||||
Key Features:
|
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
|
- Aggregates results as a list of `(name, result)` tuples
|
||||||
- Collects and reports multiple errors without interrupting execution
|
- Collects and reports multiple errors without interrupting execution
|
||||||
- Compatible with `SharedContext`, `OptionsManager`, and `last_result` injection
|
- Compatible with `SharedContext`, `OptionsManager`, and `last_result` injection
|
||||||
@@ -27,11 +26,11 @@ Raises:
|
|||||||
|
|
||||||
Example:
|
Example:
|
||||||
ActionGroup(
|
ActionGroup(
|
||||||
name="ParallelChecks",
|
name="ConcurrentChecks",
|
||||||
actions=[Action(...), Action(...), ChainedAction(...)],
|
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.
|
as opposed to depth-wise (sequential) execution.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -54,14 +53,13 @@ from falyx.themes.colors import OneColors
|
|||||||
|
|
||||||
|
|
||||||
class ActionGroup(BaseAction, ActionListMixin):
|
class ActionGroup(BaseAction, ActionListMixin):
|
||||||
"""
|
"""ActionGroup executes multiple actions concurrently.
|
||||||
ActionGroup executes multiple actions concurrently in parallel.
|
|
||||||
|
|
||||||
It is ideal for independent tasks that can be safely run simultaneously,
|
It is ideal for independent tasks that can be safely run simultaneously,
|
||||||
improving overall throughput and responsiveness of workflows.
|
improving overall throughput and responsiveness of workflows.
|
||||||
|
|
||||||
Core features:
|
Core features:
|
||||||
- Parallel execution of all contained actions.
|
- Concurrent execution of all contained actions.
|
||||||
- Shared last_result injection across all actions if configured.
|
- Shared last_result injection across all actions if configured.
|
||||||
- Aggregated collection of individual results as (name, result) pairs.
|
- Aggregated collection of individual results as (name, result) pairs.
|
||||||
- Hook lifecycle support (before, on_success, on_error, after, on_teardown).
|
- Hook lifecycle support (before, on_success, on_error, after, on_teardown).
|
||||||
@@ -75,7 +73,7 @@ class ActionGroup(BaseAction, ActionListMixin):
|
|||||||
|
|
||||||
Best used for:
|
Best used for:
|
||||||
- Batch processing multiple independent tasks.
|
- 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.
|
- Isolating errors while maximizing successful execution.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -173,7 +171,7 @@ class ActionGroup(BaseAction, ActionListMixin):
|
|||||||
combined_args = args + self.args
|
combined_args = args + self.args
|
||||||
combined_kwargs = {**self.kwargs, **kwargs}
|
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:
|
if self.shared_context:
|
||||||
shared_context.set_shared_result(self.shared_context.last_result())
|
shared_context.set_shared_result(self.shared_context.last_result())
|
||||||
updated_kwargs = self._maybe_inject_last_result(combined_kwargs)
|
updated_kwargs = self._maybe_inject_last_result(combined_kwargs)
|
||||||
@@ -229,7 +227,7 @@ class ActionGroup(BaseAction, ActionListMixin):
|
|||||||
action.register_hooks_recursively(hook_type, hook)
|
action.register_hooks_recursively(hook_type, hook)
|
||||||
|
|
||||||
async def preview(self, parent: Tree | None = None):
|
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:
|
if self.inject_last_result:
|
||||||
label.append(f" [dim](receives '{self.inject_into}')[/dim]")
|
label.append(f" [dim](receives '{self.inject_into}')[/dim]")
|
||||||
tree = parent.add("".join(label)) if parent else Tree("".join(label))
|
tree = parent.add("".join(label)) if parent else Tree("".join(label))
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Provides reusable mixins for managing collections of `BaseAction` instances
|
||||||
Provides reusable mixins for managing collections of `BaseAction` instances
|
|
||||||
within composite Falyx actions such as `ActionGroup` or `ChainedAction`.
|
within composite Falyx actions such as `ActionGroup` or `ChainedAction`.
|
||||||
|
|
||||||
The primary export, `ActionListMixin`, encapsulates common functionality for
|
The primary export, `ActionListMixin`, encapsulates common functionality for
|
||||||
@@ -8,7 +7,7 @@ maintaining a mutable list of named actions—such as adding, removing, or retri
|
|||||||
actions by name—without duplicating logic across composite action types.
|
actions by name—without duplicating logic across composite action types.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence
|
from typing import Any, Sequence
|
||||||
|
|
||||||
from falyx.action.base_action import BaseAction
|
from falyx.action.base_action import BaseAction
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Defines strongly-typed enums used throughout the Falyx CLI framework for
|
||||||
Defines strongly-typed enums used throughout the Falyx CLI framework for
|
|
||||||
representing common structured values like file formats, selection return types,
|
representing common structured values like file formats, selection return types,
|
||||||
and confirmation modes.
|
and confirmation modes.
|
||||||
|
|
||||||
@@ -28,8 +27,7 @@ from enum import Enum
|
|||||||
|
|
||||||
|
|
||||||
class FileType(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
|
Used by `LoadFileAction` and `SaveFileAction` to determine how to parse or
|
||||||
serialize file content. Includes alias resolution for common extensions like
|
serialize file content. Includes alias resolution for common extensions like
|
||||||
@@ -91,8 +89,7 @@ class FileType(Enum):
|
|||||||
|
|
||||||
|
|
||||||
class SelectionReturnType(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
|
Determines how the user's choice(s) from a `dict[str, SelectionOption]` are
|
||||||
transformed and returned by the action.
|
transformed and returned by the action.
|
||||||
@@ -145,8 +142,7 @@ class SelectionReturnType(Enum):
|
|||||||
|
|
||||||
|
|
||||||
class ConfirmType(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.
|
Used by confirmation actions to control user input behavior and available choices.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Core action system for Falyx.
|
||||||
Core action system for Falyx.
|
|
||||||
|
|
||||||
This module defines the building blocks for executable actions and workflows,
|
This module defines the building blocks for executable actions and workflows,
|
||||||
providing a structured way to compose, execute, recover, and manage sequences of
|
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.
|
- Consistent timing and execution context tracking for each run.
|
||||||
- Unified, predictable result handling and error propagation.
|
- Unified, predictable result handling and error propagation.
|
||||||
- Optional last_result injection to enable flexible, data-driven workflows.
|
- 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.
|
recovery.
|
||||||
|
|
||||||
Key components:
|
Key components:
|
||||||
- Action: wraps a function or coroutine into a standard executable unit.
|
- Action: wraps a function or coroutine into a standard executable unit.
|
||||||
- ChainedAction: runs actions sequentially, optionally injecting last results.
|
- 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.
|
- ProcessAction: executes CPU-bound functions in a separate process.
|
||||||
- LiteralInputAction: injects static values into workflows.
|
- LiteralInputAction: injects static values into workflows.
|
||||||
- FallbackAction: gracefully recovers from failures or missing data.
|
- FallbackAction: gracefully recovers from failures or missing data.
|
||||||
@@ -46,8 +45,7 @@ from falyx.themes import OneColors
|
|||||||
|
|
||||||
|
|
||||||
class BaseAction(ABC):
|
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
|
complex actions like `ChainedAction` or `ActionGroup`. They can also
|
||||||
be run independently or as part of Falyx.
|
be run independently or as part of Falyx.
|
||||||
|
|
||||||
@@ -115,8 +113,8 @@ class BaseAction(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
|
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
|
||||||
"""
|
"""Returns the callable to be used for argument inference.
|
||||||
Returns the callable to be used for argument inference.
|
|
||||||
By default, it returns None.
|
By default, it returns None.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError("get_infer_target must be implemented by subclasses")
|
raise NotImplementedError("get_infer_target must be implemented by subclasses")
|
||||||
@@ -128,9 +126,7 @@ class BaseAction(ABC):
|
|||||||
self.shared_context = shared_context
|
self.shared_context = shared_context
|
||||||
|
|
||||||
def get_option(self, option_name: str, default: Any = None) -> Any:
|
def get_option(self, option_name: str, default: Any = None) -> Any:
|
||||||
"""
|
"""Resolve an option from the OptionsManager if present, else default."""
|
||||||
Resolve an option from the OptionsManager if present, otherwise use the fallback.
|
|
||||||
"""
|
|
||||||
if self.options_manager:
|
if self.options_manager:
|
||||||
return self.options_manager.get(option_name, default)
|
return self.options_manager.get(option_name, default)
|
||||||
return default
|
return default
|
||||||
@@ -158,8 +154,8 @@ class BaseAction(ABC):
|
|||||||
def prepare(
|
def prepare(
|
||||||
self, shared_context: SharedContext, options_manager: OptionsManager | None = None
|
self, shared_context: SharedContext, options_manager: OptionsManager | None = None
|
||||||
) -> BaseAction:
|
) -> BaseAction:
|
||||||
"""
|
"""Prepare the action specifically for sequential (ChainedAction) execution.
|
||||||
Prepare the action specifically for sequential (ChainedAction) execution.
|
|
||||||
Can be overridden for chain-specific logic.
|
Can be overridden for chain-specific logic.
|
||||||
"""
|
"""
|
||||||
self.set_shared_context(shared_context)
|
self.set_shared_context(shared_context)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Defines `ChainedAction`, a core Falyx construct for executing a sequence of actions
|
||||||
Defines `ChainedAction`, a core Falyx construct for executing a sequence of actions
|
|
||||||
in strict order, optionally injecting results from previous steps into subsequent ones.
|
in strict order, optionally injecting results from previous steps into subsequent ones.
|
||||||
|
|
||||||
`ChainedAction` is designed for linear workflows where each step may depend on
|
`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):
|
class ChainedAction(BaseAction, ActionListMixin):
|
||||||
"""
|
"""ChainedAction executes a sequence of actions one after another.
|
||||||
ChainedAction executes a sequence of actions one after another.
|
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
- Supports optional automatic last_result injection (auto_inject).
|
- Supports optional automatic last_result injection (auto_inject).
|
||||||
@@ -117,6 +115,7 @@ class ChainedAction(BaseAction, ActionListMixin):
|
|||||||
name: str,
|
name: str,
|
||||||
actions: (
|
actions: (
|
||||||
Sequence[BaseAction | Callable[..., Any] | Callable[..., Awaitable[Any]]]
|
Sequence[BaseAction | Callable[..., Any] | Callable[..., Awaitable[Any]]]
|
||||||
|
| Any
|
||||||
| None
|
| None
|
||||||
) = None,
|
) = None,
|
||||||
*,
|
*,
|
||||||
@@ -276,8 +275,7 @@ class ChainedAction(BaseAction, ActionListMixin):
|
|||||||
async def _rollback(
|
async def _rollback(
|
||||||
self, rollback_stack: list[tuple[Action, tuple[Any, ...], dict[str, Any]]]
|
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,
|
Rollbacks run even if a fallback recovered from failure,
|
||||||
ensuring consistent undo of all side effects.
|
ensuring consistent undo of all side effects.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Defines `ConfirmAction`, a Falyx Action that prompts the user for confirmation
|
||||||
Defines `ConfirmAction`, a Falyx Action that prompts the user for confirmation
|
|
||||||
before continuing execution.
|
before continuing execution.
|
||||||
|
|
||||||
`ConfirmAction` supports a wide range of confirmation strategies, including:
|
`ConfirmAction` supports a wide range of confirmation strategies, including:
|
||||||
@@ -62,8 +61,7 @@ from falyx.validators import word_validator, words_validator
|
|||||||
|
|
||||||
|
|
||||||
class ConfirmAction(BaseAction):
|
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
|
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
|
yes/no prompt. You can also use a confirmation type that requires the user
|
||||||
@@ -97,8 +95,7 @@ class ConfirmAction(BaseAction):
|
|||||||
inject_last_result: bool = True,
|
inject_last_result: bool = True,
|
||||||
inject_into: str = "last_result",
|
inject_into: str = "last_result",
|
||||||
):
|
):
|
||||||
"""
|
"""Initialize the ConfirmAction.
|
||||||
Initialize the ConfirmAction.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
message (str): The confirmation message to display.
|
message (str): The confirmation message to display.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Defines `FallbackAction`, a lightweight recovery Action used within `ChainedAction`
|
||||||
Defines `FallbackAction`, a lightweight recovery Action used within `ChainedAction`
|
|
||||||
pipelines to gracefully handle errors or missing results from a preceding step.
|
pipelines to gracefully handle errors or missing results from a preceding step.
|
||||||
|
|
||||||
When placed immediately after a failing or null-returning Action, `FallbackAction`
|
When placed immediately after a failing or null-returning Action, `FallbackAction`
|
||||||
@@ -46,8 +45,7 @@ from falyx.themes import OneColors
|
|||||||
|
|
||||||
|
|
||||||
class FallbackAction(Action):
|
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.
|
returned None.
|
||||||
|
|
||||||
It injects the last result and checks:
|
It injects the last result and checks:
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Defines `HTTPAction` for making HTTP requests using aiohttp.
|
||||||
Defines an Action subclass for making HTTP requests using aiohttp within Falyx workflows.
|
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
- Automatic reuse of aiohttp.ClientSession via SharedContext
|
- Automatic reuse of aiohttp.ClientSession via SharedContext
|
||||||
@@ -32,8 +31,7 @@ async def close_shared_http_session(context: ExecutionContext) -> None:
|
|||||||
|
|
||||||
|
|
||||||
class HTTPAction(Action):
|
class HTTPAction(Action):
|
||||||
"""
|
"""An Action for executing HTTP requests using aiohttp with shared session reuse.
|
||||||
An Action for executing HTTP requests using aiohttp with shared session reuse.
|
|
||||||
|
|
||||||
This action integrates seamlessly into Falyx pipelines, with automatic session
|
This action integrates seamlessly into Falyx pipelines, with automatic session
|
||||||
management, result injection, and lifecycle hook support. It is ideal for CLI-driven
|
management, result injection, and lifecycle hook support. It is ideal for CLI-driven
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""BaseIOAction: A base class for stream- or buffer-based IO-driven Actions.
|
||||||
BaseIOAction: A base class for stream- or buffer-based IO-driven Actions.
|
|
||||||
|
|
||||||
This module defines `BaseIOAction`, a specialized variant of `BaseAction`
|
This module defines `BaseIOAction`, a specialized variant of `BaseAction`
|
||||||
that interacts with standard input and output, enabling command-line pipelines,
|
that interacts with standard input and output, enabling command-line pipelines,
|
||||||
@@ -29,8 +28,7 @@ from falyx.themes import OneColors
|
|||||||
|
|
||||||
|
|
||||||
class BaseIOAction(BaseAction):
|
class BaseIOAction(BaseAction):
|
||||||
"""
|
"""Base class for IO-driven Actions that operate on stdin/stdout input streams.
|
||||||
Base class for IO-driven Actions that operate on stdin/stdout input streams.
|
|
||||||
|
|
||||||
Designed for use in shell pipelines or programmatic workflows that pass data
|
Designed for use in shell pipelines or programmatic workflows that pass data
|
||||||
through chained commands. It handles reading input, transforming it, and
|
through chained commands. It handles reading input, transforming it, and
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Defines `LiteralInputAction`, a lightweight Falyx Action that injects a static,
|
||||||
Defines `LiteralInputAction`, a lightweight Falyx Action that injects a static,
|
|
||||||
predefined value into a `ChainedAction` workflow.
|
predefined value into a `ChainedAction` workflow.
|
||||||
|
|
||||||
This Action is useful for embedding literal values (e.g., strings, numbers,
|
This Action is useful for embedding literal values (e.g., strings, numbers,
|
||||||
@@ -43,8 +42,7 @@ from falyx.themes import OneColors
|
|||||||
|
|
||||||
|
|
||||||
class LiteralInputAction(Action):
|
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:
|
This allows embedding hardcoded values mid-pipeline, useful when:
|
||||||
- Providing default or fallback inputs.
|
- Providing default or fallback inputs.
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Defines `LoadFileAction`, a Falyx Action for reading and parsing the contents of a
|
||||||
Defines `LoadFileAction`, a Falyx Action for reading and parsing the contents of a file
|
file at runtime in a structured, introspectable, and lifecycle-aware manner.
|
||||||
at runtime in a structured, introspectable, and lifecycle-aware manner.
|
|
||||||
|
|
||||||
This action supports multiple common file types—including plain text, structured data
|
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—
|
formats (JSON, YAML, TOML), tabular formats (CSV, TSV), XML, and raw Path objects—
|
||||||
@@ -57,8 +56,7 @@ from falyx.themes import OneColors
|
|||||||
|
|
||||||
|
|
||||||
class LoadFileAction(BaseAction):
|
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,
|
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.
|
YAML, TOML, XML, CSV, and TSV—and returns a parsed representation of the file.
|
||||||
@@ -187,6 +185,7 @@ class LoadFileAction(BaseAction):
|
|||||||
|
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.error("Failed to parse %s: %s", self.file_path.name, error)
|
logger.error("Failed to parse %s: %s", self.file_path.name, error)
|
||||||
|
raise
|
||||||
return value
|
return value
|
||||||
|
|
||||||
async def _run(self, *args, **kwargs) -> Any:
|
async def _run(self, *args, **kwargs) -> Any:
|
||||||
@@ -243,7 +242,7 @@ class LoadFileAction(BaseAction):
|
|||||||
for line in preview_lines:
|
for line in preview_lines:
|
||||||
content_tree.add(f"[dim]{line}[/]")
|
content_tree.add(f"[dim]{line}[/]")
|
||||||
elif self.file_type in {FileType.JSON, FileType.YAML, FileType.TOML}:
|
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:
|
if raw is not None:
|
||||||
preview_str = (
|
preview_str = (
|
||||||
json.dumps(raw, indent=2)
|
json.dumps(raw, indent=2)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Defines `MenuAction`, a one-shot, interactive menu-style Falyx Action that presents
|
||||||
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
|
a set of labeled options to the user and executes the corresponding action based on
|
||||||
their selection.
|
their selection.
|
||||||
|
|
||||||
@@ -57,8 +56,7 @@ from falyx.utils import chunks
|
|||||||
|
|
||||||
|
|
||||||
class MenuAction(BaseAction):
|
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.
|
each mapped to a corresponding Action.
|
||||||
|
|
||||||
Unlike the main Falyx menu system, `MenuAction` is intended for scoped,
|
Unlike the main Falyx menu system, `MenuAction` is intended for scoped,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Defines `ProcessAction`, a Falyx Action that executes a blocking or CPU-bound function
|
||||||
Defines `ProcessAction`, a Falyx Action that executes a blocking or CPU-bound function
|
|
||||||
in a separate process using `concurrent.futures.ProcessPoolExecutor`.
|
in a separate process using `concurrent.futures.ProcessPoolExecutor`.
|
||||||
|
|
||||||
This is useful for offloading expensive computations or subprocess-compatible operations
|
This is useful for offloading expensive computations or subprocess-compatible operations
|
||||||
@@ -54,8 +53,7 @@ from falyx.themes import OneColors
|
|||||||
|
|
||||||
|
|
||||||
class ProcessAction(BaseAction):
|
class ProcessAction(BaseAction):
|
||||||
"""
|
"""ProcessAction runs a function in a separate process using ProcessPoolExecutor.
|
||||||
ProcessAction runs a function in a separate process using ProcessPoolExecutor.
|
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
- Executes CPU-bound or blocking tasks without blocking the main event loop.
|
- Executes CPU-bound or blocking tasks without blocking the main event loop.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Defines `ProcessPoolAction`, a parallelized action executor that distributes
|
||||||
Defines `ProcessPoolAction`, a parallelized action executor that distributes
|
|
||||||
tasks across multiple processes using Python's `concurrent.futures.ProcessPoolExecutor`.
|
tasks across multiple processes using Python's `concurrent.futures.ProcessPoolExecutor`.
|
||||||
|
|
||||||
This module enables structured execution of CPU-bound tasks in parallel while
|
This module enables structured execution of CPU-bound tasks in parallel while
|
||||||
@@ -37,8 +36,7 @@ from falyx.themes import OneColors
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ProcessTask:
|
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
|
This lightweight container is used to queue individual tasks for execution
|
||||||
inside a `ProcessPoolAction`.
|
inside a `ProcessPoolAction`.
|
||||||
@@ -62,8 +60,7 @@ class ProcessTask:
|
|||||||
|
|
||||||
|
|
||||||
class ProcessPoolAction(BaseAction):
|
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
|
`ProcessPoolAction` is ideal for CPU-bound tasks that benefit from
|
||||||
concurrent execution in separate processes. Each task is wrapped in a
|
concurrent execution in separate processes. Each task is wrapped in a
|
||||||
@@ -147,7 +144,7 @@ class ProcessPoolAction(BaseAction):
|
|||||||
async def _run(self, *args, **kwargs) -> Any:
|
async def _run(self, *args, **kwargs) -> Any:
|
||||||
if not self.actions:
|
if not self.actions:
|
||||||
raise EmptyPoolError(f"[{self.name}] No actions to execute.")
|
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:
|
if self.shared_context:
|
||||||
shared_context.set_shared_result(self.shared_context.last_result())
|
shared_context.set_shared_result(self.shared_context.last_result())
|
||||||
if self.inject_last_result and self.shared_context:
|
if self.inject_last_result and self.shared_context:
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Defines `PromptMenuAction`, a Falyx Action that prompts the user to choose from
|
||||||
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
|
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.
|
to a `MenuOption` that wraps a description and an executable action.
|
||||||
|
|
||||||
@@ -29,8 +28,7 @@ from falyx.themes import OneColors
|
|||||||
|
|
||||||
|
|
||||||
class PromptMenuAction(BaseAction):
|
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
|
`PromptMenuAction` is a lightweight alternative to `MenuAction`, offering a more
|
||||||
compact selection interface. Instead of rendering a full table, it displays
|
compact selection interface. Instead of rendering a full table, it displays
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Defines `SaveFileAction`, a Falyx Action for writing structured or unstructured data
|
||||||
Defines `SaveFileAction`, a Falyx Action for writing structured or unstructured data
|
|
||||||
to a file in a variety of supported formats.
|
to a file in a variety of supported formats.
|
||||||
|
|
||||||
Supports overwrite control, automatic directory creation, and full lifecycle hook
|
Supports overwrite control, automatic directory creation, and full lifecycle hook
|
||||||
@@ -41,8 +40,7 @@ from falyx.themes import OneColors
|
|||||||
|
|
||||||
|
|
||||||
class SaveFileAction(BaseAction):
|
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
|
`SaveFileAction` serializes and writes input data to disk using the format
|
||||||
defined by `file_type`. It supports plain text and structured formats like
|
defined by `file_type`. It supports plain text and structured formats like
|
||||||
@@ -101,8 +99,7 @@ class SaveFileAction(BaseAction):
|
|||||||
inject_last_result: bool = False,
|
inject_last_result: bool = False,
|
||||||
inject_into: str = "data",
|
inject_into: str = "data",
|
||||||
):
|
):
|
||||||
"""
|
"""SaveFileAction allows saving data to a file.
|
||||||
SaveFileAction allows saving data to a file.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): Name of the action.
|
name (str): Name of the action.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Defines `SelectFileAction`, a Falyx Action that allows users to select one or more
|
||||||
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,
|
files from a target directory and optionally return either their content or path,
|
||||||
parsed based on a selected `FileType`.
|
parsed based on a selected `FileType`.
|
||||||
|
|
||||||
@@ -72,8 +71,7 @@ from falyx.themes import OneColors
|
|||||||
|
|
||||||
|
|
||||||
class SelectFileAction(BaseAction):
|
class SelectFileAction(BaseAction):
|
||||||
"""
|
"""SelectFileAction allows users to select a file(s) from a directory and return:
|
||||||
SelectFileAction allows users to select a file(s) from a directory and return:
|
|
||||||
- file content (as text, JSON, CSV, etc.)
|
- file content (as text, JSON, CSV, etc.)
|
||||||
- or the file path itself.
|
- or the file path itself.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Defines `SelectionAction`, a highly flexible Falyx Action for interactive or
|
||||||
Defines `SelectionAction`, a highly flexible Falyx Action for interactive or headless
|
headless selection from a list or dictionary of user-defined options.
|
||||||
selection from a list or dictionary of user-defined options.
|
|
||||||
|
|
||||||
This module powers workflows that require prompting the user for input, selecting
|
This module powers workflows that require prompting the user for input, selecting
|
||||||
configuration presets, branching execution paths, or collecting multiple values
|
configuration presets, branching execution paths, or collecting multiple values
|
||||||
@@ -56,9 +55,8 @@ from falyx.themes import OneColors
|
|||||||
|
|
||||||
|
|
||||||
class SelectionAction(BaseAction):
|
class SelectionAction(BaseAction):
|
||||||
"""
|
"""A Falyx Action for interactively or programmatically selecting one or more
|
||||||
A Falyx Action for interactively or programmatically selecting one or more items
|
items from a list or dictionary of options.
|
||||||
from a list or dictionary of options.
|
|
||||||
|
|
||||||
`SelectionAction` supports both `list[str]` and `dict[str, SelectionOption]`
|
`SelectionAction` supports both `list[str]` and `dict[str, SelectionOption]`
|
||||||
inputs. It renders a prompt (unless `never_prompt=True`), validates user input
|
inputs. It renders a prompt (unless `never_prompt=True`), validates user input
|
||||||
@@ -90,7 +88,12 @@ class SelectionAction(BaseAction):
|
|||||||
allow_duplicates (bool): Whether duplicate selections are allowed.
|
allow_duplicates (bool): Whether duplicate selections are allowed.
|
||||||
inject_last_result (bool): If True, attempts to inject the last result as default.
|
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").
|
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.
|
prompt_session (PromptSession | None): Reused or customized prompt_toolkit session.
|
||||||
never_prompt (bool): If True, skips prompting and uses default_selection or last_result.
|
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.
|
show_table (bool): Whether to render the selection table before prompting.
|
||||||
|
|||||||
@@ -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."""
|
"""Execute shell commands with input substitution."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -16,8 +16,7 @@ from falyx.themes import OneColors
|
|||||||
|
|
||||||
|
|
||||||
class ShellAction(BaseIOAction):
|
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),
|
This Action takes parsed input (from stdin, literal, or last_result),
|
||||||
substitutes it into the provided shell command template, and executes
|
substitutes it into the provided shell command template, and executes
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Defines `SignalAction`, a lightweight Falyx Action that raises a `FlowSignal`
|
||||||
Defines `SignalAction`, a lightweight Falyx Action that raises a `FlowSignal`
|
|
||||||
(such as `BackSignal`, `QuitSignal`, or `BreakChainSignal`) during execution to
|
(such as `BackSignal`, `QuitSignal`, or `BreakChainSignal`) during execution to
|
||||||
alter or exit the CLI flow.
|
alter or exit the CLI flow.
|
||||||
|
|
||||||
@@ -33,8 +32,7 @@ from falyx.themes import OneColors
|
|||||||
|
|
||||||
|
|
||||||
class SignalAction(Action):
|
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`,
|
`SignalAction` raises a `FlowSignal` (e.g., `BackSignal`, `QuitSignal`,
|
||||||
`BreakChainSignal`) during execution. It is commonly used to exit menus,
|
`BreakChainSignal`) during execution. It is commonly used to exit menus,
|
||||||
@@ -59,8 +57,7 @@ class SignalAction(Action):
|
|||||||
super().__init__(name, action=self.raise_signal, hooks=hooks)
|
super().__init__(name, action=self.raise_signal, hooks=hooks)
|
||||||
|
|
||||||
async def raise_signal(self, *args, **kwargs):
|
async def raise_signal(self, *args, **kwargs):
|
||||||
"""
|
"""Raises the configured `FlowSignal`.
|
||||||
Raises the configured `FlowSignal`.
|
|
||||||
|
|
||||||
This method is called internally by the Falyx runtime and is the core
|
This method is called internally by the Falyx runtime and is the core
|
||||||
behavior of the action. All hooks surrounding execution are still triggered.
|
behavior of the action. All hooks surrounding execution are still triggered.
|
||||||
@@ -74,8 +71,7 @@ class SignalAction(Action):
|
|||||||
|
|
||||||
@signal.setter
|
@signal.setter
|
||||||
def signal(self, value: FlowSignal):
|
def signal(self, value: FlowSignal):
|
||||||
"""
|
"""Validates that the provided value is a `FlowSignal`.
|
||||||
Validates that the provided value is a `FlowSignal`.
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
TypeError: If `value` is not an instance of `FlowSignal`.
|
TypeError: If `value` is not an instance of `FlowSignal`.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Defines `UserInputAction`, a Falyx Action that prompts the user for input using
|
||||||
Defines `UserInputAction`, a Falyx Action that prompts the user for input using
|
|
||||||
Prompt Toolkit and returns the result as a string.
|
Prompt Toolkit and returns the result as a string.
|
||||||
|
|
||||||
This action is ideal for interactive CLI workflows that require user input mid-pipeline.
|
This action is ideal for interactive CLI workflows that require user input mid-pipeline.
|
||||||
@@ -40,8 +39,7 @@ from falyx.themes.colors import OneColors
|
|||||||
|
|
||||||
|
|
||||||
class UserInputAction(BaseAction):
|
class UserInputAction(BaseAction):
|
||||||
"""
|
"""Prompts the user for textual input and returns their response.
|
||||||
Prompts the user for textual input and returns their response.
|
|
||||||
|
|
||||||
`UserInputAction` uses Prompt Toolkit to gather input with optional validation,
|
`UserInputAction` uses Prompt Toolkit to gather input with optional validation,
|
||||||
lifecycle hook compatibility, and support for default text. If `inject_last_result`
|
lifecycle hook compatibility, and support for default text. If `inject_last_result`
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Provides the `BottomBar` class for managing a customizable bottom status bar in
|
||||||
Provides the `BottomBar` class for managing a customizable bottom status bar in
|
|
||||||
Falyx-based CLI applications.
|
Falyx-based CLI applications.
|
||||||
|
|
||||||
The bottom bar is rendered using `prompt_toolkit` and supports:
|
The bottom bar is rendered using `prompt_toolkit` and supports:
|
||||||
@@ -72,6 +71,11 @@ class BottomBar:
|
|||||||
self.toggle_keys: list[str] = []
|
self.toggle_keys: list[str] = []
|
||||||
self.key_bindings = key_bindings or KeyBindings()
|
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
|
@staticmethod
|
||||||
def default_render(label: str, value: Any, fg: str, bg: str, width: int) -> HTML:
|
def default_render(label: str, value: Any, fg: str, bg: str, width: int) -> HTML:
|
||||||
return HTML(f"<style fg='{fg}' bg='{bg}'>{label}: {value:^{width}}</style>")
|
return HTML(f"<style fg='{fg}' bg='{bg}'>{label}: {value:^{width}}</style>")
|
||||||
@@ -202,7 +206,7 @@ class BottomBar:
|
|||||||
label: str,
|
label: str,
|
||||||
options: OptionsManager,
|
options: OptionsManager,
|
||||||
option_name: str,
|
option_name: str,
|
||||||
namespace_name: str = "cli_args",
|
namespace_name: str = "default",
|
||||||
fg: str = OneColors.BLACK,
|
fg: str = OneColors.BLACK,
|
||||||
bg_on: str = OneColors.GREEN,
|
bg_on: str = OneColors.GREEN,
|
||||||
bg_off: str = OneColors.DARK_RED,
|
bg_off: str = OneColors.DARK_RED,
|
||||||
|
|||||||
667
falyx/command.py
667
falyx/command.py
@@ -1,19 +1,43 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Command abstraction for the Falyx CLI framework.
|
||||||
Defines the Command class for Falyx CLI.
|
|
||||||
|
|
||||||
Commands are callable units representing a menu option or CLI task,
|
This module defines the `Command` class, which represents a single executable
|
||||||
wrapping either a BaseAction or a simple function. They provide:
|
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
|
- Execution timing and duration tracking
|
||||||
- Retry logic (single action or recursively through action trees)
|
|
||||||
- Confirmation prompts and spinner integration
|
- Confirmation prompts and spinner integration
|
||||||
- Result capturing and summary logging
|
|
||||||
- Rich-based preview for CLI display
|
|
||||||
|
|
||||||
Every Command is self-contained, configurable, and plays a critical role
|
Execution Model:
|
||||||
in building robust interactive menus.
|
1. CLI input is routed via FalyxParser into a resolved Command
|
||||||
|
2. Arguments are parsed via CommandArgumentParser
|
||||||
|
3. Parsed values are split into:
|
||||||
|
- positional args
|
||||||
|
- keyword args
|
||||||
|
- execution args (e.g. retries, summary)
|
||||||
|
4. Execution occurs via the bound Action with lifecycle hooks applied
|
||||||
|
5. Results and context are tracked via ExecutionContext / ExecutionRegistry
|
||||||
|
|
||||||
|
Key Concepts:
|
||||||
|
- Commands are *user-facing entrypoints*, not execution units themselves
|
||||||
|
- Execution is always delegated to an underlying Action or callable
|
||||||
|
- Argument parsing is declarative and optional
|
||||||
|
- Execution options are handled separately from business logic inputs
|
||||||
|
|
||||||
|
This module defines the primary abstraction used by Falyx to expose structured,
|
||||||
|
composable workflows as CLI commands.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -22,17 +46,20 @@ from typing import Any, Awaitable, Callable
|
|||||||
|
|
||||||
from prompt_toolkit.formatted_text import FormattedText
|
from prompt_toolkit.formatted_text import FormattedText
|
||||||
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
|
||||||
|
from rich.style import Style
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
from falyx.action.action import Action
|
from falyx.action.action import Action
|
||||||
from falyx.action.base_action import BaseAction
|
from falyx.action.base_action import BaseAction
|
||||||
from falyx.console import console
|
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.debug import register_debug_hooks
|
||||||
|
from falyx.exceptions import CommandArgumentError, InvalidHookError, NotAFalyxError
|
||||||
|
from falyx.execution_option import ExecutionOption
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
from falyx.hook_manager import HookManager, HookType
|
from falyx.hook_manager import HookManager, HookType
|
||||||
|
from falyx.hooks import spinner_before_hook, spinner_teardown_hook
|
||||||
from falyx.logger import logger
|
from falyx.logger import logger
|
||||||
from falyx.mode import FalyxMode
|
|
||||||
from falyx.options_manager import OptionsManager
|
from falyx.options_manager import OptionsManager
|
||||||
from falyx.parser.command_argument_parser import CommandArgumentParser
|
from falyx.parser.command_argument_parser import CommandArgumentParser
|
||||||
from falyx.parser.signature import infer_args_from_func
|
from falyx.parser.signature import infer_args_from_func
|
||||||
@@ -46,67 +73,104 @@ from falyx.utils import ensure_async
|
|||||||
|
|
||||||
|
|
||||||
class Command(BaseModel):
|
class Command(BaseModel):
|
||||||
"""
|
"""Represents a user-invokable command in Falyx.
|
||||||
Represents a selectable command in a Falyx menu system.
|
|
||||||
|
|
||||||
A Command wraps an executable action (function, coroutine, or BaseAction)
|
A `Command` encapsulates all metadata, parsing logic, and execution behavior
|
||||||
and enhances it with:
|
required to expose a callable workflow through the Falyx CLI or interactive
|
||||||
|
menu system.
|
||||||
|
|
||||||
- Lifecycle hooks (before, success, error, after, teardown)
|
It is responsible for:
|
||||||
- Retry support (single action or recursive for chained/grouped actions)
|
- Identifying the command via key and aliases
|
||||||
- Confirmation prompts for safe execution
|
- Binding an executable Action or callable
|
||||||
- Spinner visuals during execution
|
- Parsing user-provided arguments
|
||||||
- Tagging for categorization and filtering
|
- Managing execution configuration (retries, confirmation, etc.)
|
||||||
- Rich-based CLI previews
|
- Integrating with lifecycle hooks and execution context
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
- Parsing is delegated to CommandArgumentParser
|
||||||
|
- Execution is delegated to BaseAction / Action
|
||||||
|
- Runtime configuration is managed via OptionsManager
|
||||||
|
- Lifecycle hooks are managed via HookManager
|
||||||
|
|
||||||
|
Argument Handling:
|
||||||
|
- Supports positional and keyword arguments via CommandArgumentParser
|
||||||
|
- Separates execution-specific options (e.g. retries, confirm flags)
|
||||||
|
from action arguments
|
||||||
|
- Returns structured `(args, kwargs, execution_args)` for execution
|
||||||
|
|
||||||
|
Execution Behavior:
|
||||||
|
- Callable via `await command(*args, **kwargs)`
|
||||||
|
- Applies lifecycle hooks:
|
||||||
|
before → on_success/on_error → after → on_teardown
|
||||||
|
- Supports preview mode for dry-run introspection
|
||||||
|
- Supports retry policies and confirmation flows
|
||||||
- Result tracking and summary reporting
|
- Result tracking and summary reporting
|
||||||
|
|
||||||
Commands are built to be flexible yet robust, enabling dynamic CLI workflows
|
Help & Introspection:
|
||||||
without sacrificing control or reliability.
|
- Provides usage, help text, and TLDR examples
|
||||||
|
- Supports both CLI help and interactive menu rendering
|
||||||
|
- Can expose simplified or full help signatures
|
||||||
|
|
||||||
Attributes:
|
Args:
|
||||||
key (str): Primary trigger key for the command.
|
key (str): Primary identifier used to invoke the command.
|
||||||
description (str): Short description for the menu display.
|
description (str): Short description for the menu display.
|
||||||
hidden (bool): Toggles visibility in the menu.
|
action (BaseAction | Callable[..., Any]):
|
||||||
aliases (list[str]): Alternate keys or phrases.
|
Execution logic for the command.
|
||||||
action (BaseAction | Callable): The executable logic.
|
args (tuple, optional): Static positional arguments.
|
||||||
args (tuple): Static positional arguments.
|
kwargs (dict[str, Any], optional): Static keyword arguments.
|
||||||
kwargs (dict): Static keyword arguments.
|
hidden (bool): Whether to hide the command from menus.
|
||||||
help_text (str): Additional help or guidance text.
|
aliases (list[str], optional): Alternate names for invocation.
|
||||||
style (str): Rich style for description.
|
help_text (str): Help description shown in CLI/menu.
|
||||||
confirm (bool): Whether to require confirmation before executing.
|
help_epilog (str): Additional help content.
|
||||||
confirm_message (str): Custom confirmation prompt.
|
style (Style | str): Rich style used for rendering.
|
||||||
preview_before_confirm (bool): Whether to preview before confirming.
|
confirm (bool): Whether confirmation is required before execution.
|
||||||
spinner (bool): Whether to show a spinner during execution.
|
confirm_message (str): Confirmation prompt text.
|
||||||
spinner_message (str): Spinner text message.
|
preview_before_confirm (bool): Whether to preview before confirmation.
|
||||||
spinner_type (str): Spinner style (e.g., dots, line, etc.).
|
spinner (bool): Enable spinner during execution.
|
||||||
spinner_style (str): Color or style of the spinner.
|
spinner_message (str): Spinner message text.
|
||||||
spinner_speed (float): Speed of the spinner animation.
|
spinner_type (str): Rich Spinner animation type (e.g., dots, line, etc.).
|
||||||
hooks (HookManager): Hook manager for lifecycle events.
|
spinner_style (Style | str): Rich style for the spinner.
|
||||||
retry (bool): Enable retry on failure.
|
spinner_speed (float): Spinner speed multiplier.
|
||||||
retry_all (bool): Enable retry across chained or grouped actions.
|
hooks (HookManager | None): Hook manager for lifecycle events.
|
||||||
retry_policy (RetryPolicy): Retry behavior configuration.
|
tags (list[str], optional): Tags for grouping and filtering.
|
||||||
tags (list[str]): Organizational tags for the command.
|
logging_hooks (bool): Enable debug logging hooks.
|
||||||
logging_hooks (bool): Whether to attach logging hooks automatically.
|
retry (bool): Enable retry behavior.
|
||||||
options_manager (OptionsManager): Manages global command-line options.
|
retry_all (bool): Apply retry to all nested actions.
|
||||||
arg_parser (CommandArgumentParser): Parses command arguments.
|
retry_policy (RetryPolicy | None): Retry configuration.
|
||||||
arguments (list[dict[str, Any]]): Argument definitions for the command.
|
arg_parser (CommandArgumentParser | None):
|
||||||
argument_config (Callable[[CommandArgumentParser], None] | None): Function to configure arguments
|
Custom argument parser instance.
|
||||||
for the command parser.
|
execution_options (frozenset[ExecutionOption], optional):
|
||||||
custom_parser (ArgParserProtocol | None): Custom argument parser.
|
Enabled execution-level options.
|
||||||
custom_help (Callable[[], str | None] | None): Custom help message generator.
|
arguments (list[dict[str, Any]], optional):
|
||||||
auto_args (bool): Automatically infer arguments from the action.
|
Declarative argument definitions.
|
||||||
arg_metadata (dict[str, str | dict[str, Any]]): Metadata for arguments,
|
argument_config (Callable[[CommandArgumentParser], None] | None):
|
||||||
such as help text or choices.
|
Callback to configure parser.
|
||||||
simple_help_signature (bool): Whether to use a simplified help signature.
|
custom_parser (ArgParserProtocol | None):
|
||||||
ignore_in_history (bool): Whether to ignore this command in execution history last result.
|
Override parser logic entirely.
|
||||||
program: (str | None): The parent program name.
|
custom_help (Callable[[], str | None] | None):
|
||||||
|
Override help rendering.
|
||||||
|
custom_tldr (Callable[[], str | None] | None):
|
||||||
|
Override TLDR rendering.
|
||||||
|
custom_usage (Callable[[], str | None] | None):
|
||||||
|
Override usage rendering.
|
||||||
|
auto_args (bool): Auto-generate arguments from action signature.
|
||||||
|
arg_metadata (dict[str, Any], optional): Metadata for arguments.
|
||||||
|
simple_help_signature (bool): Use simplified help formatting.
|
||||||
|
ignore_in_history (bool):
|
||||||
|
Ignore command for `last_result` in execution history.
|
||||||
|
options_manager (OptionsManager | None):
|
||||||
|
Shared options manager instance.
|
||||||
|
program (str | None): The parent program name.
|
||||||
|
|
||||||
Methods:
|
Raises:
|
||||||
__call__(): Executes the command, respecting hooks and retries.
|
CommandArgumentError: If argument parsing fails.
|
||||||
preview(): Rich tree preview of the command.
|
InvalidActionError: If action is not callable or invalid.
|
||||||
confirmation_prompt(): Formatted prompt for confirmation.
|
FalyxError: If command configuration is invalid.
|
||||||
result: Property exposing the last result.
|
|
||||||
log_summary(): Summarizes execution details to the console.
|
Notes:
|
||||||
|
- Commands are lightweight wrappers; execution logic belongs in Actions
|
||||||
|
- Argument parsing and execution are intentionally decoupled
|
||||||
|
- Commands are case-insensitive and support alias resolution
|
||||||
"""
|
"""
|
||||||
|
|
||||||
key: str
|
key: str
|
||||||
@@ -118,14 +182,14 @@ class Command(BaseModel):
|
|||||||
aliases: list[str] = Field(default_factory=list)
|
aliases: list[str] = Field(default_factory=list)
|
||||||
help_text: str = ""
|
help_text: str = ""
|
||||||
help_epilog: str = ""
|
help_epilog: str = ""
|
||||||
style: str = OneColors.WHITE
|
style: Style | str = OneColors.WHITE
|
||||||
confirm: bool = False
|
confirm: bool = False
|
||||||
confirm_message: str = "Are you sure?"
|
confirm_message: str = "Are you sure?"
|
||||||
preview_before_confirm: bool = True
|
preview_before_confirm: bool = True
|
||||||
spinner: bool = False
|
spinner: bool = False
|
||||||
spinner_message: str = "Processing..."
|
spinner_message: str = "Processing..."
|
||||||
spinner_type: str = "dots"
|
spinner_type: str = "dots"
|
||||||
spinner_style: str = OneColors.CYAN
|
spinner_style: Style | str = OneColors.CYAN
|
||||||
spinner_speed: float = 1.0
|
spinner_speed: float = 1.0
|
||||||
hooks: "HookManager" = Field(default_factory=HookManager)
|
hooks: "HookManager" = Field(default_factory=HookManager)
|
||||||
retry: bool = False
|
retry: bool = False
|
||||||
@@ -135,10 +199,13 @@ class Command(BaseModel):
|
|||||||
logging_hooks: bool = False
|
logging_hooks: bool = False
|
||||||
options_manager: OptionsManager = Field(default_factory=OptionsManager)
|
options_manager: OptionsManager = Field(default_factory=OptionsManager)
|
||||||
arg_parser: CommandArgumentParser | None = None
|
arg_parser: CommandArgumentParser | None = None
|
||||||
|
execution_options: frozenset[ExecutionOption] = Field(default_factory=frozenset)
|
||||||
arguments: list[dict[str, Any]] = Field(default_factory=list)
|
arguments: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
argument_config: Callable[[CommandArgumentParser], None] | None = None
|
argument_config: Callable[[CommandArgumentParser], None] | None = None
|
||||||
custom_parser: ArgParserProtocol | None = None
|
custom_parser: ArgParserProtocol | None = None
|
||||||
custom_help: Callable[[], str | None] | None = None
|
custom_help: Callable[[], str | None] | None = None
|
||||||
|
custom_tldr: Callable[[], str | None] | None = None
|
||||||
|
custom_usage: Callable[[], str | None] | None = None
|
||||||
auto_args: bool = True
|
auto_args: bool = True
|
||||||
arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict)
|
arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict)
|
||||||
simple_help_signature: bool = False
|
simple_help_signature: bool = False
|
||||||
@@ -149,52 +216,104 @@ class Command(BaseModel):
|
|||||||
|
|
||||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
|
|
||||||
async def parse_args(
|
async def resolve_args(
|
||||||
self, raw_args: list[str] | str, from_validate: bool = False
|
self,
|
||||||
) -> tuple[tuple, dict]:
|
raw_args: list[str] | str,
|
||||||
if callable(self.custom_parser):
|
from_validate: bool = False,
|
||||||
|
invocation_context: InvocationContext | None = None,
|
||||||
|
) -> tuple[tuple, dict, dict]:
|
||||||
|
"""Parse CLI arguments into execution-ready components.
|
||||||
|
|
||||||
|
This method delegates argument parsing to the configured
|
||||||
|
CommandArgumentParser (if present) and normalizes the result into three
|
||||||
|
distinct groups used during execution:
|
||||||
|
|
||||||
|
- positional arguments (`args`)
|
||||||
|
- keyword arguments (`kwargs`)
|
||||||
|
- execution arguments (`execution_args`)
|
||||||
|
|
||||||
|
Execution arguments represent runtime configuration (e.g. retries,
|
||||||
|
confirmation flags, summary output) and are handled separately from the
|
||||||
|
action's business logic inputs.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- If an argument parser is defined, uses `CommandArgumentParser.parse_args_split()`
|
||||||
|
to resolve and type-coerce all inputs.
|
||||||
|
- If no parser is defined, returns empty args and kwargs.
|
||||||
|
- Supports validation mode (`from_validate=True`) for interactive input,
|
||||||
|
deferring certain errors and resolver execution where applicable.
|
||||||
|
- Handles help/preview signals raised during parsing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args (list[str] | str | None): CLI-style argument tokens or a single string.
|
||||||
|
from_validate (bool): Whether parsing is occurring in validation mode
|
||||||
|
(e.g. prompt_toolkit validator). When True, may suppress eager
|
||||||
|
resolution or defer certain errors.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple:
|
||||||
|
- tuple[Any, ...]: Positional arguments for execution.
|
||||||
|
- dict[str, Any]: Keyword arguments for execution.
|
||||||
|
- dict[str, Any]: Execution-specific arguments (e.g. retries,
|
||||||
|
confirm flags, summary).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
CommandArgumentError: If argument parsing or validation fails.
|
||||||
|
HelpSignal: If help or TLDR output is triggered during parsing.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Execution arguments are not passed to the underlying Action.
|
||||||
|
- This method is the canonical boundary between CLI parsing and
|
||||||
|
execution semantics.
|
||||||
|
"""
|
||||||
|
if self.custom_parser is not None:
|
||||||
|
if not callable(self.custom_parser):
|
||||||
|
raise NotAFalyxError(
|
||||||
|
"custom_parser must be a callable that implements ArgParserProtocol."
|
||||||
|
)
|
||||||
if isinstance(raw_args, str):
|
if isinstance(raw_args, str):
|
||||||
try:
|
try:
|
||||||
raw_args = shlex.split(raw_args)
|
raw_args = shlex.split(raw_args)
|
||||||
except ValueError:
|
except ValueError as error:
|
||||||
logger.warning(
|
raise CommandArgumentError(
|
||||||
"[Command:%s] Failed to split arguments: %s",
|
f"[{self.key}] Failed to parse arguments: {error}"
|
||||||
self.key,
|
) from error
|
||||||
raw_args,
|
|
||||||
)
|
|
||||||
return ((), {})
|
|
||||||
return self.custom_parser(raw_args)
|
return self.custom_parser(raw_args)
|
||||||
|
|
||||||
if isinstance(raw_args, str):
|
if isinstance(raw_args, str):
|
||||||
try:
|
try:
|
||||||
raw_args = shlex.split(raw_args)
|
raw_args = shlex.split(raw_args)
|
||||||
except ValueError:
|
except ValueError as error:
|
||||||
logger.warning(
|
raise CommandArgumentError(
|
||||||
"[Command:%s] Failed to split arguments: %s",
|
f"[{self.key}] Failed to parse arguments: {error}"
|
||||||
self.key,
|
) from error
|
||||||
raw_args,
|
|
||||||
|
if self.arg_parser is None:
|
||||||
|
raise NotAFalyxError(
|
||||||
|
"Command has no parser configured. "
|
||||||
|
"Provide a custom_parser or CommandArgumentParser."
|
||||||
)
|
)
|
||||||
return ((), {})
|
|
||||||
if not isinstance(self.arg_parser, CommandArgumentParser):
|
if not isinstance(self.arg_parser, CommandArgumentParser):
|
||||||
logger.warning(
|
raise NotAFalyxError(
|
||||||
"[Command:%s] No argument parser configured, using default parsing.",
|
"arg_parser must be an instance of CommandArgumentParser"
|
||||||
self.key,
|
|
||||||
)
|
)
|
||||||
return ((), {})
|
|
||||||
return await self.arg_parser.parse_args_split(
|
return await self.arg_parser.parse_args_split(
|
||||||
raw_args, from_validate=from_validate
|
raw_args,
|
||||||
|
from_validate=from_validate,
|
||||||
|
invocation_context=invocation_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
@field_validator("action", mode="before")
|
@field_validator("action", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def wrap_callable_as_async(cls, action: Any) -> Any:
|
def _wrap_callable_as_async(cls, action: Any) -> Any:
|
||||||
if isinstance(action, BaseAction):
|
if isinstance(action, BaseAction):
|
||||||
return action
|
return action
|
||||||
elif callable(action):
|
elif callable(action):
|
||||||
return ensure_async(action)
|
return ensure_async(action)
|
||||||
raise TypeError("Action must be a callable or an instance of BaseAction")
|
raise TypeError("Action must be a callable or an instance of BaseAction")
|
||||||
|
|
||||||
def get_argument_definitions(self) -> list[dict[str, Any]]:
|
def _get_argument_definitions(self) -> list[dict[str, Any]]:
|
||||||
if self.arguments:
|
if self.arguments:
|
||||||
return self.arguments
|
return self.arguments
|
||||||
elif callable(self.argument_config) and isinstance(
|
elif callable(self.argument_config) and isinstance(
|
||||||
@@ -246,9 +365,15 @@ class Command(BaseModel):
|
|||||||
program=self.program,
|
program=self.program,
|
||||||
options_manager=self.options_manager,
|
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)
|
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):
|
if self.ignore_in_history and isinstance(self.action, BaseAction):
|
||||||
self.action.ignore_in_history = True
|
self.action.ignore_in_history = True
|
||||||
|
|
||||||
@@ -258,9 +383,41 @@ class Command(BaseModel):
|
|||||||
self.action.set_options_manager(self.options_manager)
|
self.action.set_options_manager(self.options_manager)
|
||||||
|
|
||||||
async def __call__(self, *args, **kwargs) -> Any:
|
async def __call__(self, *args, **kwargs) -> Any:
|
||||||
"""
|
"""Execute the command's underlying action with lifecycle management.
|
||||||
Run the action with full hook lifecycle, timing, error handling,
|
|
||||||
confirmation prompts, preview, and spinner integration.
|
This method invokes the bound action (BaseAction or callable) using the
|
||||||
|
provided arguments while applying the full Falyx execution lifecycle.
|
||||||
|
|
||||||
|
Execution Flow:
|
||||||
|
1. Create an ExecutionContext for tracking inputs, results, and timing
|
||||||
|
2. Trigger `before` hooks
|
||||||
|
3. Execute the underlying action
|
||||||
|
4. Trigger `on_success` or `on_error` hooks
|
||||||
|
5. Trigger `after` and `on_teardown` hooks
|
||||||
|
6. Record execution via ExecutionRegistry
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- Supports both synchronous and asynchronous actions
|
||||||
|
- Applies retry policies if configured
|
||||||
|
- Integrates with confirmation and execution options via OptionsManager
|
||||||
|
- Propagates exceptions unless recovered by hooks (e.g. retry handlers)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
*args (Any): Positional arguments passed to the action.
|
||||||
|
**kwargs (Any): Keyword arguments passed to the action.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Any: Result returned by the underlying action.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: Propagates execution errors unless handled by hooks.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- This method does not perform argument parsing; inputs are assumed
|
||||||
|
to be pre-processed via `resolve_args`.
|
||||||
|
- Execution options (e.g. retries, confirm) are applied externally
|
||||||
|
via Falyx in OptionsManager before invocation.
|
||||||
|
- Lifecycle hooks are always executed, even in failure cases.
|
||||||
"""
|
"""
|
||||||
self._inject_options_manager()
|
self._inject_options_manager()
|
||||||
combined_args = args + self.args
|
combined_args = args + self.args
|
||||||
@@ -276,7 +433,7 @@ class Command(BaseModel):
|
|||||||
if should_prompt_user(confirm=self.confirm, options=self.options_manager):
|
if should_prompt_user(confirm=self.confirm, options=self.options_manager):
|
||||||
if self.preview_before_confirm:
|
if self.preview_before_confirm:
|
||||||
await self.preview()
|
await self.preview()
|
||||||
if not await confirm_async(self.confirmation_prompt):
|
if not await confirm_async(self._confirmation_prompt):
|
||||||
logger.info("[Command:%s] Cancelled by user.", self.key)
|
logger.info("[Command:%s] Cancelled by user.", self.key)
|
||||||
raise CancelSignal(f"[Command:{self.key}] Cancelled by confirmation.")
|
raise CancelSignal(f"[Command:{self.key}] Cancelled by confirmation.")
|
||||||
|
|
||||||
@@ -305,7 +462,7 @@ class Command(BaseModel):
|
|||||||
return self._context.result if self._context else None
|
return self._context.result if self._context else None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def confirmation_prompt(self) -> FormattedText:
|
def _confirmation_prompt(self) -> FormattedText:
|
||||||
"""Generate a styled prompt_toolkit FormattedText confirmation message."""
|
"""Generate a styled prompt_toolkit FormattedText confirmation message."""
|
||||||
if self.confirm_message and self.confirm_message != "Are you sure?":
|
if self.confirm_message and self.confirm_message != "Are you sure?":
|
||||||
return FormattedText([("class:confirm", self.confirm_message)])
|
return FormattedText([("class:confirm", self.confirm_message)])
|
||||||
@@ -329,30 +486,59 @@ class Command(BaseModel):
|
|||||||
|
|
||||||
return FormattedText(prompt)
|
return FormattedText(prompt)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_alias(self) -> str:
|
||||||
|
"""Get the primary alias for the command, used in help displays."""
|
||||||
|
if self.aliases:
|
||||||
|
return self.aliases[0].lower()
|
||||||
|
return self.key
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def usage(self) -> str:
|
def usage(self) -> str:
|
||||||
"""Generate a help string for the command arguments."""
|
"""Generate a help string for the command arguments."""
|
||||||
if not self.arg_parser:
|
if not self.arg_parser:
|
||||||
return "No arguments defined."
|
return "No arguments defined."
|
||||||
|
|
||||||
command_keys_text = self.arg_parser.get_command_keys_text(plain_text=True)
|
command_keys_text = self.arg_parser.get_command_keys_text()
|
||||||
options_text = self.arg_parser.get_options_text(plain_text=True)
|
options_text = self.arg_parser.get_options_text()
|
||||||
return f" {command_keys_text:<20} {options_text} "
|
return f" {command_keys_text:<20} {options_text} "
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def help_signature(self) -> tuple[str, str, str]:
|
def help_signature(
|
||||||
"""Generate a help signature for the command."""
|
self,
|
||||||
is_cli_mode = self.options_manager.get("mode") in {
|
invocation_context: InvocationContext | None = None,
|
||||||
FalyxMode.RUN,
|
) -> tuple[str, str, str]:
|
||||||
FalyxMode.PREVIEW,
|
"""Return a formatted help signature for display.
|
||||||
FalyxMode.RUN_ALL,
|
|
||||||
FalyxMode.HELP,
|
|
||||||
}
|
|
||||||
|
|
||||||
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:
|
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]"
|
description = f"[dim]{self.help_text or self.description}[/dim]"
|
||||||
if self.tags:
|
if self.tags:
|
||||||
tags = f"[dim]Tags: {', '.join(self.tags)}[/dim]"
|
tags = f"[dim]Tags: {', '.join(self.tags)}[/dim]"
|
||||||
@@ -365,7 +551,7 @@ class Command(BaseModel):
|
|||||||
+ [f"[{self.style}]{alias}[/{self.style}]" for alias in self.aliases]
|
+ [f"[{self.style}]{alias}[/{self.style}]" for alias in self.aliases]
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
f"[{self.style}]{program}[/]{command_keys}",
|
f"{command_keys}",
|
||||||
f"[dim]{self.help_text or self.description}[/dim]",
|
f"[dim]{self.help_text or self.description}[/dim]",
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
@@ -374,7 +560,19 @@ class Command(BaseModel):
|
|||||||
if self._context:
|
if self._context:
|
||||||
self._context.log_summary()
|
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."""
|
"""Display the help message for the command."""
|
||||||
if callable(self.custom_help):
|
if callable(self.custom_help):
|
||||||
output = self.custom_help()
|
output = self.custom_help()
|
||||||
@@ -382,7 +580,19 @@ class Command(BaseModel):
|
|||||||
console.print(output)
|
console.print(output)
|
||||||
return True
|
return True
|
||||||
if isinstance(self.arg_parser, CommandArgumentParser):
|
if isinstance(self.arg_parser, CommandArgumentParser):
|
||||||
self.arg_parser.render_help()
|
self.arg_parser.render_help(invocation_context)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def render_tldr(self, invocation_context: InvocationContext | None = None) -> bool:
|
||||||
|
"""Display the TLDR message for the command."""
|
||||||
|
if callable(self.custom_tldr):
|
||||||
|
output = self.custom_tldr()
|
||||||
|
if output:
|
||||||
|
console.print(output)
|
||||||
|
return True
|
||||||
|
if isinstance(self.arg_parser, CommandArgumentParser):
|
||||||
|
self.arg_parser.render_tldr(invocation_context)
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -416,3 +626,232 @@ class Command(BaseModel):
|
|||||||
f"Command(key='{self.key}', description='{self.description}' "
|
f"Command(key='{self.key}', description='{self.description}' "
|
||||||
f"action='{self.action}')"
|
f"action='{self.action}')"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def build(
|
||||||
|
cls,
|
||||||
|
key: str,
|
||||||
|
description: str,
|
||||||
|
action: BaseAction | Callable[..., Any],
|
||||||
|
*,
|
||||||
|
args: tuple = (),
|
||||||
|
kwargs: dict[str, Any] | None = None,
|
||||||
|
hidden: bool = False,
|
||||||
|
aliases: list[str] | None = None,
|
||||||
|
help_text: str = "",
|
||||||
|
help_epilog: str = "",
|
||||||
|
style: Style | str = OneColors.WHITE,
|
||||||
|
confirm: bool = False,
|
||||||
|
confirm_message: str = "Are you sure?",
|
||||||
|
preview_before_confirm: bool = True,
|
||||||
|
spinner: bool = False,
|
||||||
|
spinner_message: str = "Processing...",
|
||||||
|
spinner_type: str = "dots",
|
||||||
|
spinner_style: Style | str = OneColors.CYAN,
|
||||||
|
spinner_speed: float = 1.0,
|
||||||
|
options_manager: OptionsManager | None = None,
|
||||||
|
hooks: HookManager | None = None,
|
||||||
|
before_hooks: list[Callable] | None = None,
|
||||||
|
success_hooks: list[Callable] | None = None,
|
||||||
|
error_hooks: list[Callable] | None = None,
|
||||||
|
after_hooks: list[Callable] | None = None,
|
||||||
|
teardown_hooks: list[Callable] | None = None,
|
||||||
|
tags: list[str] | None = None,
|
||||||
|
logging_hooks: bool = False,
|
||||||
|
retry: bool = False,
|
||||||
|
retry_all: bool = False,
|
||||||
|
retry_policy: RetryPolicy | None = None,
|
||||||
|
arg_parser: CommandArgumentParser | None = None,
|
||||||
|
arguments: list[dict[str, Any]] | None = None,
|
||||||
|
argument_config: Callable[[CommandArgumentParser], None] | None = None,
|
||||||
|
execution_options: list[ExecutionOption | str] | None = None,
|
||||||
|
custom_parser: ArgParserProtocol | None = None,
|
||||||
|
custom_help: Callable[[], str | None] | None = None,
|
||||||
|
custom_tldr: Callable[[], str | None] | None = None,
|
||||||
|
custom_usage: Callable[[], str | None] | None = None,
|
||||||
|
auto_args: bool = True,
|
||||||
|
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
|
||||||
|
simple_help_signature: bool = False,
|
||||||
|
ignore_in_history: bool = False,
|
||||||
|
program: str | None = None,
|
||||||
|
) -> Command:
|
||||||
|
"""Build and configure a `Command` instance from high-level constructor inputs.
|
||||||
|
|
||||||
|
This factory centralizes command construction so callers such as `Falyx` and
|
||||||
|
`CommandRunner` can create fully configured commands through one consistent
|
||||||
|
path. It normalizes optional inputs, validates selected objects, converts
|
||||||
|
execution options into their canonical internal form, and registers any
|
||||||
|
requested command-level hooks.
|
||||||
|
|
||||||
|
In addition to instantiating the `Command`, this method can:
|
||||||
|
- validate and attach an explicit `CommandArgumentParser`
|
||||||
|
- normalize execution options into a `frozenset[ExecutionOption]`
|
||||||
|
- ensure a shared `OptionsManager` is available
|
||||||
|
- attach a custom `HookManager`
|
||||||
|
- register lifecycle hooks for the command
|
||||||
|
- register spinner hooks when spinner support is enabled
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key (str): Primary identifier used to invoke the command.
|
||||||
|
description (str): Short description of the command.
|
||||||
|
action (BaseAction | Callable[..., Any]): Underlying execution logic for
|
||||||
|
the command.
|
||||||
|
args (tuple): Static positional arguments applied to every execution.
|
||||||
|
kwargs (dict[str, Any] | None): Static keyword arguments applied to every
|
||||||
|
execution.
|
||||||
|
hidden (bool): Whether the command should be hidden from menu displays.
|
||||||
|
aliases (list[str] | None): Optional alternate names for invocation.
|
||||||
|
help_text (str): Help text shown in command help output.
|
||||||
|
help_epilog (str): Additional help text shown after the main help body.
|
||||||
|
style (Style | str): Rich style used when rendering the command.
|
||||||
|
confirm (bool): Whether confirmation is required before execution.
|
||||||
|
confirm_message (str): Confirmation prompt text.
|
||||||
|
preview_before_confirm (bool): Whether to preview before confirmation.
|
||||||
|
spinner (bool): Whether to enable spinner lifecycle hooks.
|
||||||
|
spinner_message (str): Spinner message text.
|
||||||
|
spinner_type (str): Spinner animation type.
|
||||||
|
spinner_style (Style | str): Spinner style.
|
||||||
|
spinner_speed (float): Spinner speed multiplier.
|
||||||
|
options_manager (OptionsManager | None): Shared options manager for the
|
||||||
|
command and its parser.
|
||||||
|
hooks (HookManager | None): Optional hook manager to assign directly to the
|
||||||
|
command.
|
||||||
|
before_hooks (list[Callable] | None): Hooks registered for the `BEFORE`
|
||||||
|
lifecycle stage.
|
||||||
|
success_hooks (list[Callable] | None): Hooks registered for the
|
||||||
|
`ON_SUCCESS` lifecycle stage.
|
||||||
|
error_hooks (list[Callable] | None): Hooks registered for the `ON_ERROR`
|
||||||
|
lifecycle stage.
|
||||||
|
after_hooks (list[Callable] | None): Hooks registered for the `AFTER`
|
||||||
|
lifecycle stage.
|
||||||
|
teardown_hooks (list[Callable] | None): Hooks registered for the
|
||||||
|
`ON_TEARDOWN` lifecycle stage.
|
||||||
|
tags (list[str] | None): Optional tags used for grouping and filtering.
|
||||||
|
logging_hooks (bool): Whether to enable debug hook logging.
|
||||||
|
retry (bool): Whether retry behavior is enabled.
|
||||||
|
retry_all (bool): Whether retry behavior should be applied recursively.
|
||||||
|
retry_policy (RetryPolicy | None): Retry configuration for the command.
|
||||||
|
arg_parser (CommandArgumentParser | None): Optional explicit argument
|
||||||
|
parser instance.
|
||||||
|
arguments (list[dict[str, Any]] | None): Declarative argument
|
||||||
|
definitions for the command parser.
|
||||||
|
argument_config (Callable[[CommandArgumentParser], None] | None): Callback
|
||||||
|
used to configure the argument parser.
|
||||||
|
execution_options (list[ExecutionOption | str] | None): Execution-level
|
||||||
|
options to enable for the command.
|
||||||
|
custom_parser (ArgParserProtocol | None): Optional custom parser
|
||||||
|
implementation that overrides normal parser behavior.
|
||||||
|
custom_help (Callable[[], str | None] | None): Optional custom help
|
||||||
|
renderer.
|
||||||
|
custom_tldr (Callable[[], str | None] | None): Optional custom TLDR
|
||||||
|
renderer.
|
||||||
|
custom_usage (Callable[[], str | None] | None): Optional custom usage
|
||||||
|
renderer.
|
||||||
|
auto_args (bool): Whether to infer arguments automatically from the action
|
||||||
|
signature when explicit definitions are not provided.
|
||||||
|
arg_metadata (dict[str, str | dict[str, Any]] | None): Optional metadata
|
||||||
|
used during argument inference.
|
||||||
|
simple_help_signature (bool): Whether to use a simplified help signature.
|
||||||
|
ignore_in_history (bool): Whether to exclude the command from execution
|
||||||
|
history tracking.
|
||||||
|
program (str | None): Parent program name used in help rendering.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Command: A fully configured `Command` instance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotAFalyxError: If `arg_parser` is provided but is not a
|
||||||
|
`CommandArgumentParser` instance.
|
||||||
|
InvalidHookError: If `hooks` is provided but is not a `HookManager` instance.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Execution options supplied as strings are converted to
|
||||||
|
`ExecutionOption` enum values before the command is created.
|
||||||
|
- If no `options_manager` is provided, a new `OptionsManager` is created.
|
||||||
|
- Spinner hooks are registered at build time when `spinner=True`.
|
||||||
|
- This method is the canonical command-construction path used by higher-
|
||||||
|
level APIs such as `Falyx.add_command()` and `CommandRunner.build()`.
|
||||||
|
"""
|
||||||
|
if arg_parser and not isinstance(arg_parser, CommandArgumentParser):
|
||||||
|
raise NotAFalyxError(
|
||||||
|
"arg_parser must be an instance of CommandArgumentParser."
|
||||||
|
)
|
||||||
|
arg_parser = arg_parser
|
||||||
|
|
||||||
|
if options_manager and not isinstance(options_manager, OptionsManager):
|
||||||
|
raise NotAFalyxError("options_manager must be an instance of OptionsManager.")
|
||||||
|
options_manager = options_manager or OptionsManager()
|
||||||
|
|
||||||
|
if hooks and not isinstance(hooks, HookManager):
|
||||||
|
raise InvalidHookError("hooks must be an instance of HookManager.")
|
||||||
|
hooks = hooks or HookManager()
|
||||||
|
|
||||||
|
if retry_policy and not isinstance(retry_policy, RetryPolicy):
|
||||||
|
raise NotAFalyxError("retry_policy must be an instance of RetryPolicy.")
|
||||||
|
retry_policy = retry_policy or RetryPolicy()
|
||||||
|
|
||||||
|
if execution_options:
|
||||||
|
parsed_execution_options = frozenset(
|
||||||
|
ExecutionOption(option) if isinstance(option, str) else option
|
||||||
|
for option in execution_options
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
parsed_execution_options = frozenset()
|
||||||
|
|
||||||
|
command = Command(
|
||||||
|
key=key,
|
||||||
|
description=description,
|
||||||
|
action=action,
|
||||||
|
args=args,
|
||||||
|
kwargs=kwargs if kwargs else {},
|
||||||
|
hidden=hidden,
|
||||||
|
aliases=aliases if aliases else [],
|
||||||
|
help_text=help_text,
|
||||||
|
help_epilog=help_epilog,
|
||||||
|
style=style,
|
||||||
|
confirm=confirm,
|
||||||
|
confirm_message=confirm_message,
|
||||||
|
preview_before_confirm=preview_before_confirm,
|
||||||
|
spinner=spinner,
|
||||||
|
spinner_message=spinner_message,
|
||||||
|
spinner_type=spinner_type,
|
||||||
|
spinner_style=spinner_style,
|
||||||
|
spinner_speed=spinner_speed,
|
||||||
|
tags=tags if tags else [],
|
||||||
|
logging_hooks=logging_hooks,
|
||||||
|
hooks=hooks,
|
||||||
|
retry=retry,
|
||||||
|
retry_all=retry_all,
|
||||||
|
retry_policy=retry_policy,
|
||||||
|
options_manager=options_manager,
|
||||||
|
arg_parser=arg_parser,
|
||||||
|
execution_options=parsed_execution_options,
|
||||||
|
arguments=arguments or [],
|
||||||
|
argument_config=argument_config,
|
||||||
|
custom_parser=custom_parser,
|
||||||
|
custom_help=custom_help,
|
||||||
|
custom_tldr=custom_tldr,
|
||||||
|
custom_usage=custom_usage,
|
||||||
|
auto_args=auto_args,
|
||||||
|
arg_metadata=arg_metadata or {},
|
||||||
|
simple_help_signature=simple_help_signature,
|
||||||
|
ignore_in_history=ignore_in_history,
|
||||||
|
program=program,
|
||||||
|
)
|
||||||
|
|
||||||
|
for hook in before_hooks or []:
|
||||||
|
command.hooks.register(HookType.BEFORE, hook)
|
||||||
|
for hook in success_hooks or []:
|
||||||
|
command.hooks.register(HookType.ON_SUCCESS, hook)
|
||||||
|
for hook in error_hooks or []:
|
||||||
|
command.hooks.register(HookType.ON_ERROR, hook)
|
||||||
|
for hook in after_hooks or []:
|
||||||
|
command.hooks.register(HookType.AFTER, hook)
|
||||||
|
for hook in teardown_hooks or []:
|
||||||
|
command.hooks.register(HookType.ON_TEARDOWN, hook)
|
||||||
|
|
||||||
|
if spinner:
|
||||||
|
command.hooks.register(HookType.BEFORE, spinner_before_hook)
|
||||||
|
command.hooks.register(HookType.ON_TEARDOWN, spinner_teardown_hook)
|
||||||
|
|
||||||
|
return command
|
||||||
|
|||||||
311
falyx/command_executor.py
Normal file
311
falyx/command_executor.py
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
|
"""Shared command execution engine for the Falyx CLI framework.
|
||||||
|
|
||||||
|
This module defines `CommandExecutor`, a low-level execution service responsible
|
||||||
|
for running already-resolved `Command` objects with a consistent outer lifecycle.
|
||||||
|
|
||||||
|
`CommandExecutor` sits between higher-level orchestration layers (such as
|
||||||
|
`Falyx.execute_command()` or `CommandRunner.run()`) and the command itself.
|
||||||
|
It does not perform command lookup or argument parsing. Instead, it accepts a
|
||||||
|
resolved `Command` plus prepared `args`, `kwargs`, and `execution_args`, then
|
||||||
|
applies executor-level behavior around the command invocation.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
- Apply execution-scoped runtime overrides such as confirmation flags
|
||||||
|
- Apply retry overrides from execution arguments
|
||||||
|
- Trigger executor-level lifecycle hooks
|
||||||
|
- Create and manage an outer `ExecutionContext`
|
||||||
|
- Delegate actual invocation to the resolved `Command`
|
||||||
|
- Handle interruption and failure policies
|
||||||
|
- Optionally print execution summaries via `ExecutionRegistry`
|
||||||
|
|
||||||
|
Execution Model:
|
||||||
|
1. A command is resolved and its arguments are prepared elsewhere.
|
||||||
|
2. Retry and execution-option overrides are derived from `execution_args`.
|
||||||
|
3. An outer `ExecutionContext` is created for executor-level tracking.
|
||||||
|
4. Executor hooks are triggered around the command invocation.
|
||||||
|
5. The command is executed inside an `OptionsManager.override_namespace()`
|
||||||
|
context for scoped runtime overrides.
|
||||||
|
6. Errors are either surfaced, wrapped, or rendered depending on the
|
||||||
|
configured execution policy.
|
||||||
|
7. Optional summary output is emitted after execution completes.
|
||||||
|
|
||||||
|
Design Notes:
|
||||||
|
- `CommandExecutor` is intentionally narrower in scope than `Falyx`.
|
||||||
|
It does not resolve commands, parse raw CLI text, or manage menu state.
|
||||||
|
- `Command` still owns command-local behavior such as confirmation,
|
||||||
|
command hooks, and delegation to the underlying `Action`.
|
||||||
|
- This module exists to centralize shared execution behavior and reduce
|
||||||
|
duplication across Falyx runtime entrypoints.
|
||||||
|
|
||||||
|
Typical Usage:
|
||||||
|
executor = CommandExecutor(options=options, hooks=hooks)
|
||||||
|
result = await executor.execute(
|
||||||
|
command=command,
|
||||||
|
args=args,
|
||||||
|
kwargs=kwargs,
|
||||||
|
execution_args=execution_args,
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from falyx.action import Action
|
||||||
|
from falyx.command import Command
|
||||||
|
from falyx.context import ExecutionContext
|
||||||
|
from falyx.exceptions import FalyxError
|
||||||
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
|
from falyx.hook_manager import HookManager, HookType
|
||||||
|
from falyx.logger import logger
|
||||||
|
from falyx.options_manager import OptionsManager
|
||||||
|
|
||||||
|
|
||||||
|
class CommandExecutor:
|
||||||
|
"""Execute resolved Falyx commands with shared outer lifecycle handling.
|
||||||
|
|
||||||
|
`CommandExecutor` provides a reusable execution service for running a
|
||||||
|
`Command` after command resolution and argument parsing have already been
|
||||||
|
completed.
|
||||||
|
|
||||||
|
This class is intended to be shared by higher-level entrypoints such as
|
||||||
|
`Falyx` and `CommandRunner`. It centralizes the outer execution flow so
|
||||||
|
command execution semantics remain consistent across menu-driven and
|
||||||
|
programmatic use cases.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
- Apply retry overrides from execution arguments
|
||||||
|
- Apply scoped runtime overrides using `OptionsManager`
|
||||||
|
- Trigger executor-level hooks before and after command execution
|
||||||
|
- Create and manage an executor-level `ExecutionContext`
|
||||||
|
- Control whether errors are raised or wrapped
|
||||||
|
- Emit optional execution summaries
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
options (OptionsManager): Shared options manager used to apply scoped
|
||||||
|
execution overrides.
|
||||||
|
hooks (HookManager): Hook manager for executor-level lifecycle hooks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
options: OptionsManager,
|
||||||
|
hooks: HookManager,
|
||||||
|
) -> None:
|
||||||
|
self.options = options
|
||||||
|
self.hooks = hooks
|
||||||
|
|
||||||
|
def _debug_hooks(self, command: Command) -> None:
|
||||||
|
"""Log executor-level and command-level hook registrations for debugging.
|
||||||
|
|
||||||
|
This helper is used to surface the currently registered hooks on both the
|
||||||
|
executor and the resolved command before execution begins.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command (Command): The command about to be executed.
|
||||||
|
"""
|
||||||
|
logger.debug("executor hooks:\n%s", str(self.hooks))
|
||||||
|
logger.debug("['%s'] hooks:\n%s", command.key, str(command.hooks))
|
||||||
|
|
||||||
|
def _apply_retry_overrides(
|
||||||
|
self,
|
||||||
|
command: Command,
|
||||||
|
execution_args: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Apply retry-related execution overrides to the command.
|
||||||
|
|
||||||
|
This method inspects execution-level retry options and updates the
|
||||||
|
command's retry policy in place when overrides are provided. If the
|
||||||
|
command's underlying action is an `Action`, the updated retry policy is
|
||||||
|
propagated to that action as well.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command (Command): The command whose retry policy may be updated.
|
||||||
|
execution_args (dict[str, Any]): Execution-level arguments that may
|
||||||
|
contain retry overrides such as `retries`, `retry_delay`, and
|
||||||
|
`retry_backoff`.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- If no retry-related overrides are provided, this method does nothing.
|
||||||
|
- If the command action is not an `Action`, a warning is logged and the
|
||||||
|
command-level retry policy is updated without propagating it further.
|
||||||
|
"""
|
||||||
|
retries = execution_args.get("retries", 0)
|
||||||
|
retry_delay = execution_args.get("retry_delay", 0.0)
|
||||||
|
retry_backoff = execution_args.get("retry_backoff", 0.0)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"[_apply_retry_overrides]: retries=%s, retry_delay=%s, retry_backoff=%s",
|
||||||
|
retries,
|
||||||
|
retry_delay,
|
||||||
|
retry_backoff,
|
||||||
|
)
|
||||||
|
if not retries and not retry_delay and not retry_backoff:
|
||||||
|
return
|
||||||
|
|
||||||
|
command.retry_policy.enabled = True
|
||||||
|
if retries:
|
||||||
|
command.retry_policy.max_retries = retries
|
||||||
|
if retry_delay:
|
||||||
|
command.retry_policy.delay = retry_delay
|
||||||
|
if retry_backoff:
|
||||||
|
command.retry_policy.backoff = retry_backoff
|
||||||
|
|
||||||
|
if isinstance(command.action, Action):
|
||||||
|
command.action.set_retry_policy(command.retry_policy)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"[%s] Retry requested, but action is not an Action instance.",
|
||||||
|
command.key,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execution_option_overrides(
|
||||||
|
self,
|
||||||
|
execution_args: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build scoped option overrides from execution arguments.
|
||||||
|
|
||||||
|
This method extracts execution-only runtime flags that should be applied
|
||||||
|
through the `OptionsManager` during command execution.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
execution_args (dict[str, Any]): Execution-level arguments returned
|
||||||
|
from command argument resolution.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Any]: Mapping of option names to temporary execution-scoped
|
||||||
|
override values.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"force_confirm": execution_args.get("force_confirm", False),
|
||||||
|
"skip_confirm": execution_args.get("skip_confirm", False),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
command: Command,
|
||||||
|
args: tuple,
|
||||||
|
kwargs: dict[str, Any],
|
||||||
|
execution_args: dict[str, Any],
|
||||||
|
raise_on_error: bool = True,
|
||||||
|
wrap_errors: bool = False,
|
||||||
|
summary_last_result: bool = False,
|
||||||
|
) -> Any:
|
||||||
|
"""Execute a resolved command with executor-level lifecycle management.
|
||||||
|
|
||||||
|
This method is the primary entrypoint of `CommandExecutor`. It accepts an
|
||||||
|
already-resolved `Command` and its prepared execution inputs, then applies
|
||||||
|
shared outer execution behavior around the command invocation.
|
||||||
|
|
||||||
|
Execution Flow:
|
||||||
|
1. Log currently registered hooks for debugging.
|
||||||
|
2. Apply retry overrides from `execution_args`.
|
||||||
|
3. Derive scoped runtime overrides for the execution namespace.
|
||||||
|
4. Create and start an outer `ExecutionContext`.
|
||||||
|
5. Trigger executor-level `BEFORE` hooks.
|
||||||
|
6. Execute the command inside an execution-scoped options override
|
||||||
|
context.
|
||||||
|
7. Trigger executor-level `SUCCESS` or `ERROR` hooks.
|
||||||
|
8. Trigger `AFTER` and `ON_TEARDOWN` hooks.
|
||||||
|
9. Optionally print an execution summary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command (Command): The resolved command to execute.
|
||||||
|
args (tuple): Positional arguments to pass to the command.
|
||||||
|
kwargs (dict[str, Any]): Keyword arguments to pass to the command.
|
||||||
|
execution_args (dict[str, Any]): Execution-only arguments that affect
|
||||||
|
runtime behavior, such as retry or confirmation overrides.
|
||||||
|
raise_on_error (bool): Whether execution errors should be re-raised
|
||||||
|
after handling.
|
||||||
|
wrap_errors (bool): Whether handled errors should be wrapped in a
|
||||||
|
`FalyxError` before being raised.
|
||||||
|
summary_last_result (bool): Whether summary output should only have the
|
||||||
|
last recorded result when summary reporting is enabled.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Any: The result returned by the command, or any recovered result
|
||||||
|
attached to the execution context.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyboardInterrupt: If execution is interrupted by the user and
|
||||||
|
`raise_on_error` is True and `wrap_errors` is False.
|
||||||
|
EOFError: If execution receives EOF interruption and `raise_on_error`
|
||||||
|
is True and `wrap_errors` is False.
|
||||||
|
FalyxError: If `wrap_errors` is True and execution is interrupted or
|
||||||
|
fails.
|
||||||
|
Exception: Re-raises the underlying execution error when
|
||||||
|
`raise_on_error` is True and `wrap_errors` is False.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- This method assumes the command has already been resolved and its
|
||||||
|
arguments have already been parsed.
|
||||||
|
- Command-local behavior, such as confirmation prompts and command hook
|
||||||
|
execution, remains the responsibility of `Command.__call__()`.
|
||||||
|
- Summary output is only emitted when the `summary` execution option is
|
||||||
|
present in `execution_args`.
|
||||||
|
"""
|
||||||
|
if not (raise_on_error or wrap_errors):
|
||||||
|
raise FalyxError(
|
||||||
|
"CommandExecutor.execute() requires either raise_on_error=True "
|
||||||
|
"or wrap_errors=True."
|
||||||
|
)
|
||||||
|
self._debug_hooks(command)
|
||||||
|
self._apply_retry_overrides(command, execution_args)
|
||||||
|
overrides = self._execution_option_overrides(execution_args)
|
||||||
|
|
||||||
|
context = ExecutionContext(
|
||||||
|
name=command.description,
|
||||||
|
args=args,
|
||||||
|
kwargs=kwargs,
|
||||||
|
action=command,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"[execute] Starting execution of '%s' with args: %s, kwargs: %s",
|
||||||
|
command.description,
|
||||||
|
args,
|
||||||
|
kwargs,
|
||||||
|
)
|
||||||
|
context.start_timer()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.hooks.trigger(HookType.BEFORE, context)
|
||||||
|
with self.options.override_namespace(
|
||||||
|
overrides=overrides,
|
||||||
|
namespace_name="execution",
|
||||||
|
):
|
||||||
|
result = await command(*args, **kwargs)
|
||||||
|
context.result = result
|
||||||
|
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||||
|
except (KeyboardInterrupt, EOFError) as error:
|
||||||
|
logger.info(
|
||||||
|
"[execute] '%s' interrupted by user.",
|
||||||
|
command.key,
|
||||||
|
)
|
||||||
|
if wrap_errors:
|
||||||
|
raise FalyxError(
|
||||||
|
f"[execute] '{command.key}' interrupted by user."
|
||||||
|
) from error
|
||||||
|
raise error
|
||||||
|
except Exception as error:
|
||||||
|
logger.debug(
|
||||||
|
"[execute] '%s' failed: %s",
|
||||||
|
command.key,
|
||||||
|
error,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
context.exception = error
|
||||||
|
await self.hooks.trigger(HookType.ON_ERROR, context)
|
||||||
|
if wrap_errors:
|
||||||
|
raise FalyxError(f"[execute] '{command.key}' failed: {error}") from error
|
||||||
|
raise error
|
||||||
|
finally:
|
||||||
|
context.stop_timer()
|
||||||
|
await self.hooks.trigger(HookType.AFTER, context)
|
||||||
|
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
||||||
|
if execution_args.get("summary") and summary_last_result:
|
||||||
|
er.summary(last_result=True)
|
||||||
|
elif execution_args.get("summary"):
|
||||||
|
er.summary()
|
||||||
|
return context.result
|
||||||
531
falyx/command_runner.py
Normal file
531
falyx/command_runner.py
Normal file
@@ -0,0 +1,531 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
|
"""Standalone command runner for the Falyx CLI framework.
|
||||||
|
|
||||||
|
This module defines `CommandRunner`, a developer-facing convenience wrapper for
|
||||||
|
executing a single `Command` outside the full `Falyx` runtime.
|
||||||
|
|
||||||
|
`CommandRunner` is designed for programmatic and standalone command execution
|
||||||
|
where command lookup, menu interaction, and root CLI parsing are not needed.
|
||||||
|
It provides a small, focused API that:
|
||||||
|
|
||||||
|
- owns a single `Command`
|
||||||
|
- ensures the command and parser share a consistent `OptionsManager`
|
||||||
|
- delegates shared execution behavior to `CommandExecutor`
|
||||||
|
- supports both wrapping an existing `Command` and building one from raw
|
||||||
|
constructor-style arguments
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
- Hold a single resolved `Command` for repeated execution
|
||||||
|
- Normalize runtime dependencies such as `OptionsManager`, `HookManager`,
|
||||||
|
and `Console`
|
||||||
|
- Resolve command arguments from raw argv-style input
|
||||||
|
- Delegate execution to `CommandExecutor` for shared outer lifecycle
|
||||||
|
handling
|
||||||
|
|
||||||
|
Design Notes:
|
||||||
|
- `CommandRunner` is intentionally narrower than `Falyx`.
|
||||||
|
It does not resolve commands by name, render menus, or manage built-ins.
|
||||||
|
- `CommandExecutor` remains the shared execution core.
|
||||||
|
`CommandRunner` exists as a convenience layer for developer-facing and
|
||||||
|
standalone use cases.
|
||||||
|
- `Command` still owns command-local behavior such as confirmation,
|
||||||
|
command hook execution, and delegation to the underlying `Action`.
|
||||||
|
|
||||||
|
Typical Usage:
|
||||||
|
runner = CommandRunner.from_command(existing_command)
|
||||||
|
result = await runner.run(["--region", "us-east"])
|
||||||
|
|
||||||
|
#!/usr/bin/env python
|
||||||
|
import asyncio
|
||||||
|
runner = CommandRunner.build(
|
||||||
|
key="D",
|
||||||
|
description="Deploy",
|
||||||
|
action=deploy,
|
||||||
|
)
|
||||||
|
result = asyncio.run(runner.cli())
|
||||||
|
$ ./deploy.py --region us-east
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from falyx.action import BaseAction
|
||||||
|
from falyx.command import Command
|
||||||
|
from falyx.command_executor import CommandExecutor
|
||||||
|
from falyx.console import console as falyx_console
|
||||||
|
from falyx.console import error_console, print_error
|
||||||
|
from falyx.exceptions import (
|
||||||
|
CommandArgumentError,
|
||||||
|
FalyxError,
|
||||||
|
InvalidHookError,
|
||||||
|
NotAFalyxError,
|
||||||
|
)
|
||||||
|
from falyx.execution_option import ExecutionOption
|
||||||
|
from falyx.hook_manager import HookManager
|
||||||
|
from falyx.logger import logger
|
||||||
|
from falyx.options_manager import OptionsManager
|
||||||
|
from falyx.parser.command_argument_parser import CommandArgumentParser
|
||||||
|
from falyx.protocols import ArgParserProtocol
|
||||||
|
from falyx.retry import RetryPolicy
|
||||||
|
from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
|
||||||
|
from falyx.themes import OneColors
|
||||||
|
|
||||||
|
|
||||||
|
class CommandRunner:
|
||||||
|
"""Run a single Falyx command outside the full Falyx application runtime.
|
||||||
|
|
||||||
|
`CommandRunner` is a lightweight wrapper around a single `Command` plus a
|
||||||
|
`CommandExecutor`. It is intended for standalone execution, testing, and
|
||||||
|
developer-facing programmatic usage where command resolution has already
|
||||||
|
happened or is unnecessary.
|
||||||
|
|
||||||
|
This class is responsible for:
|
||||||
|
- storing the bound `Command`
|
||||||
|
- providing a shared `OptionsManager` to the command and its parser
|
||||||
|
- exposing a simple `run()` method that accepts argv-style input
|
||||||
|
- delegating shared execution behavior to `CommandExecutor`
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
command (Command): The command executed by this runner.
|
||||||
|
program (str): Program name used in CLI usage text and help output.
|
||||||
|
options (OptionsManager): Shared options manager used by the command,
|
||||||
|
parser, and executor.
|
||||||
|
runner_hooks (HookManager): Executor-level hooks used during execution.
|
||||||
|
console (Console): Rich console used for user-facing output.
|
||||||
|
executor (CommandExecutor): Shared execution engine used to run the
|
||||||
|
bound command.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
command: Command,
|
||||||
|
*,
|
||||||
|
program: str | None = None,
|
||||||
|
options: OptionsManager | None = None,
|
||||||
|
runner_hooks: HookManager | None = None,
|
||||||
|
console: Console | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize a `CommandRunner` for a single command.
|
||||||
|
|
||||||
|
The runner ensures that the bound command, its argument parser, and the
|
||||||
|
internal `CommandExecutor` all share the same `OptionsManager` and runtime
|
||||||
|
dependencies.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command (Command): The command to execute.
|
||||||
|
program (str | None): Program name used in CLI usage text, invocation-path
|
||||||
|
rendering, and built-in help output. If `None`, an empty program name is
|
||||||
|
used.
|
||||||
|
options (OptionsManager | None): Optional shared options manager. If
|
||||||
|
omitted, a new `OptionsManager` is created.
|
||||||
|
runner_hooks (HookManager | None): Optional executor-level hook manager. If
|
||||||
|
omitted, a new `HookManager` is created.
|
||||||
|
console (Console | None): Optional Rich console for output. If omitted,
|
||||||
|
the default Falyx console is used.
|
||||||
|
"""
|
||||||
|
self.command = command
|
||||||
|
self.program = program or ""
|
||||||
|
self.options = self._get_options(options)
|
||||||
|
self.runner_hooks = self._get_hooks(runner_hooks)
|
||||||
|
self.console = self._get_console(console)
|
||||||
|
self.error_console = error_console
|
||||||
|
self.command.options_manager = self.options
|
||||||
|
if program:
|
||||||
|
self.command.program = program
|
||||||
|
if isinstance(self.command.arg_parser, CommandArgumentParser):
|
||||||
|
self.command.arg_parser.set_options_manager(self.options)
|
||||||
|
self.command.arg_parser.is_runner_mode = True
|
||||||
|
if program:
|
||||||
|
self.command.arg_parser.program = program
|
||||||
|
self.executor = CommandExecutor(
|
||||||
|
options=self.options,
|
||||||
|
hooks=self.runner_hooks,
|
||||||
|
)
|
||||||
|
self.options.from_mapping(values={}, namespace_name="execution")
|
||||||
|
|
||||||
|
def _get_console(self, console) -> Console:
|
||||||
|
if console is None:
|
||||||
|
return falyx_console
|
||||||
|
elif isinstance(console, Console):
|
||||||
|
return console
|
||||||
|
else:
|
||||||
|
raise NotAFalyxError("console must be an instance of rich.Console or None.")
|
||||||
|
|
||||||
|
def _get_options(self, options) -> OptionsManager:
|
||||||
|
if options is None:
|
||||||
|
return OptionsManager()
|
||||||
|
elif isinstance(options, OptionsManager):
|
||||||
|
return options
|
||||||
|
else:
|
||||||
|
raise NotAFalyxError("options must be an instance of OptionsManager or None.")
|
||||||
|
|
||||||
|
def _get_hooks(self, hooks) -> HookManager:
|
||||||
|
if hooks is None:
|
||||||
|
return HookManager()
|
||||||
|
elif isinstance(hooks, HookManager):
|
||||||
|
return hooks
|
||||||
|
else:
|
||||||
|
raise InvalidHookError("hooks must be an instance of HookManager or None.")
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
self,
|
||||||
|
argv: list[str] | str | None = None,
|
||||||
|
raise_on_error: bool = True,
|
||||||
|
wrap_errors: bool = False,
|
||||||
|
summary_last_result: bool = False,
|
||||||
|
) -> Any:
|
||||||
|
"""Resolve arguments and execute the bound command.
|
||||||
|
|
||||||
|
This method is the primary execution entrypoint for `CommandRunner`. It
|
||||||
|
accepts raw argv-style tokens, resolves them into positional arguments,
|
||||||
|
keyword arguments, and execution arguments via `Command.resolve_args()`,
|
||||||
|
then delegates execution to the internal `CommandExecutor`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
argv (list[str] | str | None): Optional argv-style argument tokens or
|
||||||
|
string (uses `shlex.split()` if a string is provided). If omitted,
|
||||||
|
`sys.argv[1:]` is used.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Any: The result returned by the bound command.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: Propagates any execution error surfaced by the underlying
|
||||||
|
`CommandExecutor` or command execution path.
|
||||||
|
"""
|
||||||
|
argv = sys.argv[1:] if argv is None else argv
|
||||||
|
args, kwargs, execution_args = await self.command.resolve_args(argv)
|
||||||
|
logger.debug(
|
||||||
|
"Executing command '%s' with args=%s, kwargs=%s, execution_args=%s",
|
||||||
|
self.command.description,
|
||||||
|
args,
|
||||||
|
kwargs,
|
||||||
|
execution_args,
|
||||||
|
)
|
||||||
|
return await self.executor.execute(
|
||||||
|
command=self.command,
|
||||||
|
args=args,
|
||||||
|
kwargs=kwargs,
|
||||||
|
execution_args=execution_args,
|
||||||
|
raise_on_error=raise_on_error,
|
||||||
|
wrap_errors=wrap_errors,
|
||||||
|
summary_last_result=summary_last_result,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def cli(
|
||||||
|
self,
|
||||||
|
argv: list[str] | str | None = None,
|
||||||
|
summary_last_result: bool = False,
|
||||||
|
) -> Any:
|
||||||
|
"""Run the bound command as a shell-oriented CLI entrypoint.
|
||||||
|
|
||||||
|
This method wraps `run()` with command-line specific behavior. It executes the
|
||||||
|
bound command using raw argv-style input, then translates framework signals and
|
||||||
|
execution failures into user-facing console output and process exit codes.
|
||||||
|
|
||||||
|
Unlike `run()`, this method is intended for direct CLI usage rather than
|
||||||
|
programmatic integration. It may terminate the current process via `sys.exit()`.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- Delegates normal execution to `run()`
|
||||||
|
- Exits with status code `0` when help output is requested
|
||||||
|
- Exits with status code `2` for command argument or usage errors
|
||||||
|
- Exits with status code `1` for execution failures and non-success control
|
||||||
|
flow such as cancellation or back-navigation
|
||||||
|
- Exits with status code `130` for quit/interrupt-style termination
|
||||||
|
|
||||||
|
Args:
|
||||||
|
argv (list[str] | str | None): Optional argv-style argument tokens or string
|
||||||
|
(uses `shlex.split()` if a string is provided). If omitted, `sys.argv[1:]`
|
||||||
|
is used by `run()`.
|
||||||
|
summary_last_result (bool): Whether summary output should include the last
|
||||||
|
recorded result when summary reporting is enabled.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Any: The result returned by the bound command when execution completes
|
||||||
|
successfully.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SystemExit: Always raised for handled CLI exit paths, including help,
|
||||||
|
argument errors, cancellations, and execution failures.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- This method is intentionally shell-facing and should be used in
|
||||||
|
script entrypoints such as `asyncio.run(runner.cli())`.
|
||||||
|
- For programmatic use, prefer `run()`, which preserves normal Python
|
||||||
|
exception behavior and does not call `sys.exit()`.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await self.run(
|
||||||
|
argv=argv,
|
||||||
|
raise_on_error=False,
|
||||||
|
wrap_errors=True,
|
||||||
|
summary_last_result=summary_last_result,
|
||||||
|
)
|
||||||
|
except HelpSignal:
|
||||||
|
sys.exit(0)
|
||||||
|
except CommandArgumentError as error:
|
||||||
|
self.command.render_help()
|
||||||
|
print_error(message=error)
|
||||||
|
sys.exit(2)
|
||||||
|
except FalyxError as error:
|
||||||
|
print_error(message=error)
|
||||||
|
sys.exit(1)
|
||||||
|
except QuitSignal:
|
||||||
|
logger.info("[QuitSignal]. <- Exiting run.")
|
||||||
|
sys.exit(130)
|
||||||
|
except BackSignal:
|
||||||
|
logger.info("[BackSignal]. <- Exiting run.")
|
||||||
|
sys.exit(1)
|
||||||
|
except CancelSignal:
|
||||||
|
logger.info("[CancelSignal]. <- Exiting run.")
|
||||||
|
sys.exit(1)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("[asyncio.CancelledError]. <- Exiting run.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_command(
|
||||||
|
cls,
|
||||||
|
command: Command,
|
||||||
|
*,
|
||||||
|
program: str | None = None,
|
||||||
|
runner_hooks: HookManager | None = None,
|
||||||
|
options: OptionsManager | None = None,
|
||||||
|
console: Console | None = None,
|
||||||
|
) -> CommandRunner:
|
||||||
|
"""Create a `CommandRunner` from an existing `Command` instance.
|
||||||
|
|
||||||
|
This factory is useful when a command has already been defined elsewhere
|
||||||
|
and should be exposed through the standalone runner interface without
|
||||||
|
rebuilding it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command (Command): Existing command instance to wrap.
|
||||||
|
program (str | None): Program name used in CLI usage text, invocation-path
|
||||||
|
rendering, and built-in help output. If `None`, an empty program name is
|
||||||
|
used.
|
||||||
|
runner_hooks (HookManager | None): Optional executor-level hook manager
|
||||||
|
for the runner.
|
||||||
|
options (OptionsManager | None): Optional shared options manager.
|
||||||
|
console (Console | None): Optional Rich console for output.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandRunner: A runner bound to the provided command.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotAFalyxError: If `runner_hooks` is provided but is not a
|
||||||
|
`HookManager` instance.
|
||||||
|
"""
|
||||||
|
if not isinstance(command, Command):
|
||||||
|
raise NotAFalyxError("command must be an instance of Command.")
|
||||||
|
if runner_hooks and not isinstance(runner_hooks, HookManager):
|
||||||
|
raise InvalidHookError("runner_hooks must be an instance of HookManager.")
|
||||||
|
return cls(
|
||||||
|
command=command,
|
||||||
|
program=program,
|
||||||
|
options=options,
|
||||||
|
runner_hooks=runner_hooks,
|
||||||
|
console=console,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def build(
|
||||||
|
cls,
|
||||||
|
key: str,
|
||||||
|
description: str,
|
||||||
|
action: BaseAction | Callable[..., Any],
|
||||||
|
*,
|
||||||
|
program: str | None = None,
|
||||||
|
runner_hooks: HookManager | None = None,
|
||||||
|
args: tuple = (),
|
||||||
|
kwargs: dict[str, Any] | None = None,
|
||||||
|
hidden: bool = False,
|
||||||
|
aliases: list[str] | None = None,
|
||||||
|
help_text: str = "",
|
||||||
|
help_epilog: str = "",
|
||||||
|
style: str = OneColors.WHITE,
|
||||||
|
confirm: bool = False,
|
||||||
|
confirm_message: str = "Are you sure?",
|
||||||
|
preview_before_confirm: bool = True,
|
||||||
|
spinner: bool = False,
|
||||||
|
spinner_message: str = "Processing...",
|
||||||
|
spinner_type: str = "dots",
|
||||||
|
spinner_style: str = OneColors.CYAN,
|
||||||
|
spinner_speed: float = 1.0,
|
||||||
|
options: OptionsManager | None = None,
|
||||||
|
command_hooks: HookManager | None = None,
|
||||||
|
before_hooks: list[Callable] | None = None,
|
||||||
|
success_hooks: list[Callable] | None = None,
|
||||||
|
error_hooks: list[Callable] | None = None,
|
||||||
|
after_hooks: list[Callable] | None = None,
|
||||||
|
teardown_hooks: list[Callable] | None = None,
|
||||||
|
tags: list[str] | None = None,
|
||||||
|
logging_hooks: bool = False,
|
||||||
|
retry: bool = False,
|
||||||
|
retry_all: bool = False,
|
||||||
|
retry_policy: RetryPolicy | None = None,
|
||||||
|
arg_parser: CommandArgumentParser | None = None,
|
||||||
|
arguments: list[dict[str, Any]] | None = None,
|
||||||
|
argument_config: Callable[[CommandArgumentParser], None] | None = None,
|
||||||
|
execution_options: list[ExecutionOption | str] | None = None,
|
||||||
|
custom_parser: ArgParserProtocol | None = None,
|
||||||
|
custom_help: Callable[[], str | None] | None = None,
|
||||||
|
custom_tldr: Callable[[], str | None] | None = None,
|
||||||
|
custom_usage: Callable[[], str | None] | None = None,
|
||||||
|
auto_args: bool = True,
|
||||||
|
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
|
||||||
|
simple_help_signature: bool = False,
|
||||||
|
ignore_in_history: bool = False,
|
||||||
|
console: Console | None = None,
|
||||||
|
) -> CommandRunner:
|
||||||
|
"""Build a `Command` and wrap it in a `CommandRunner`.
|
||||||
|
|
||||||
|
This factory is a convenience constructor for standalone usage. It mirrors
|
||||||
|
the high-level command-building API by creating a configured `Command`
|
||||||
|
through `Command.build()` and then returning a `CommandRunner` bound to it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key (str): Primary key used to invoke the command.
|
||||||
|
description (str): Short description of the command.
|
||||||
|
action (BaseAction | Callable[..., Any]): Underlying execution logic for
|
||||||
|
the command.
|
||||||
|
program (str | None): Program name used in CLI usage text, invocation-path
|
||||||
|
rendering, and built-in help output. If `None`, an empty program name is
|
||||||
|
used.
|
||||||
|
runner_hooks (HookManager | None): Optional executor-level hooks for the
|
||||||
|
runner.
|
||||||
|
args (tuple): Static positional arguments applied to the command.
|
||||||
|
kwargs (dict[str, Any] | None): Static keyword arguments applied to the
|
||||||
|
command.
|
||||||
|
hidden (bool): Whether the command should be hidden from menu displays.
|
||||||
|
aliases (list[str] | None): Optional alternate invocation names.
|
||||||
|
help_text (str): Help text shown in command help output.
|
||||||
|
help_epilog (str): Additional help text shown after the main help body.
|
||||||
|
style (str): Rich style used for rendering the command.
|
||||||
|
confirm (bool): Whether confirmation is required before execution.
|
||||||
|
confirm_message (str): Confirmation prompt text.
|
||||||
|
preview_before_confirm (bool): Whether to preview before confirmation.
|
||||||
|
spinner (bool): Whether to enable spinner integration.
|
||||||
|
spinner_message (str): Spinner message text.
|
||||||
|
spinner_type (str): Spinner animation type.
|
||||||
|
spinner_style (str): Spinner style.
|
||||||
|
spinner_speed (float): Spinner speed multiplier.
|
||||||
|
options (OptionsManager | None): Shared options manager for the command
|
||||||
|
and runner.
|
||||||
|
command_hooks (HookManager | None): Optional hook manager for the built
|
||||||
|
command itself.
|
||||||
|
before_hooks (list[Callable] | None): Command hooks registered for the
|
||||||
|
`BEFORE` lifecycle stage.
|
||||||
|
success_hooks (list[Callable] | None): Command hooks registered for the
|
||||||
|
`ON_SUCCESS` lifecycle stage.
|
||||||
|
error_hooks (list[Callable] | None): Command hooks registered for the
|
||||||
|
`ON_ERROR` lifecycle stage.
|
||||||
|
after_hooks (list[Callable] | None): Command hooks registered for the
|
||||||
|
`AFTER` lifecycle stage.
|
||||||
|
teardown_hooks (list[Callable] | None): Command hooks registered for the
|
||||||
|
`ON_TEARDOWN` lifecycle stage.
|
||||||
|
tags (list[str] | None): Optional tags used for grouping and filtering.
|
||||||
|
logging_hooks (bool): Whether to enable debug hook logging.
|
||||||
|
retry (bool): Whether retry behavior is enabled.
|
||||||
|
retry_all (bool): Whether retry behavior should be applied recursively.
|
||||||
|
retry_policy (RetryPolicy | None): Retry configuration for the command.
|
||||||
|
arg_parser (CommandArgumentParser | None): Optional explicit argument
|
||||||
|
parser instance.
|
||||||
|
arguments (list[dict[str, Any]] | None): Declarative argument
|
||||||
|
definitions.
|
||||||
|
argument_config (Callable[[CommandArgumentParser], None] | None):
|
||||||
|
Callback used to configure the argument parser.
|
||||||
|
execution_options (list[ExecutionOption | str] | None): Execution-level
|
||||||
|
options to enable for the command.
|
||||||
|
custom_parser (ArgParserProtocol | None): Optional custom parser
|
||||||
|
implementation.
|
||||||
|
custom_help (Callable[[], str | None] | None): Optional custom help
|
||||||
|
renderer.
|
||||||
|
custom_tldr (Callable[[], str | None] | None): Optional custom TLDR
|
||||||
|
renderer.
|
||||||
|
custom_usage (Callable[[], str | None] | None): Optional custom usage
|
||||||
|
renderer.
|
||||||
|
auto_args (bool): Whether to infer arguments automatically from the
|
||||||
|
action signature.
|
||||||
|
arg_metadata (dict[str, str | dict[str, Any]] | None): Optional
|
||||||
|
metadata used during argument inference.
|
||||||
|
simple_help_signature (bool): Whether to use a simplified help
|
||||||
|
signature.
|
||||||
|
ignore_in_history (bool): Whether to exclude the command from execution
|
||||||
|
history tracking.
|
||||||
|
console (Console | None): Optional Rich console for output.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandRunner: A runner wrapping the newly built command.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotAFalyxError: If `arg_parser` is provided but is not a
|
||||||
|
`CommandArgumentParser` instance.
|
||||||
|
InvalidHookError: If `runner_hooks` is provided but is not a `HookManager`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- This method is intended as a standalone convenience factory.
|
||||||
|
- Command construction is delegated to `Command.build()` so command
|
||||||
|
configuration remains centralized.
|
||||||
|
"""
|
||||||
|
options = options or OptionsManager()
|
||||||
|
command = Command.build(
|
||||||
|
key=key,
|
||||||
|
description=description,
|
||||||
|
action=action,
|
||||||
|
program=program,
|
||||||
|
args=args,
|
||||||
|
kwargs=kwargs,
|
||||||
|
hidden=hidden,
|
||||||
|
aliases=aliases,
|
||||||
|
help_text=help_text,
|
||||||
|
help_epilog=help_epilog,
|
||||||
|
style=style,
|
||||||
|
confirm=confirm,
|
||||||
|
confirm_message=confirm_message,
|
||||||
|
preview_before_confirm=preview_before_confirm,
|
||||||
|
spinner=spinner,
|
||||||
|
spinner_message=spinner_message,
|
||||||
|
spinner_type=spinner_type,
|
||||||
|
spinner_style=spinner_style,
|
||||||
|
spinner_speed=spinner_speed,
|
||||||
|
tags=tags,
|
||||||
|
logging_hooks=logging_hooks,
|
||||||
|
retry=retry,
|
||||||
|
retry_all=retry_all,
|
||||||
|
retry_policy=retry_policy,
|
||||||
|
options_manager=options,
|
||||||
|
hooks=command_hooks,
|
||||||
|
before_hooks=before_hooks,
|
||||||
|
success_hooks=success_hooks,
|
||||||
|
error_hooks=error_hooks,
|
||||||
|
after_hooks=after_hooks,
|
||||||
|
teardown_hooks=teardown_hooks,
|
||||||
|
arg_parser=arg_parser,
|
||||||
|
execution_options=execution_options,
|
||||||
|
arguments=arguments,
|
||||||
|
argument_config=argument_config,
|
||||||
|
custom_parser=custom_parser,
|
||||||
|
custom_help=custom_help,
|
||||||
|
custom_tldr=custom_tldr,
|
||||||
|
custom_usage=custom_usage,
|
||||||
|
auto_args=auto_args,
|
||||||
|
arg_metadata=arg_metadata,
|
||||||
|
simple_help_signature=simple_help_signature,
|
||||||
|
ignore_in_history=ignore_in_history,
|
||||||
|
)
|
||||||
|
|
||||||
|
if runner_hooks and not isinstance(runner_hooks, HookManager):
|
||||||
|
raise InvalidHookError("runner_hooks must be an instance of HookManager.")
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
command=command,
|
||||||
|
options=options,
|
||||||
|
runner_hooks=runner_hooks,
|
||||||
|
console=console,
|
||||||
|
)
|
||||||
@@ -1,22 +1,32 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Prompt Toolkit completion support for routed Falyx command input.
|
||||||
Provides `FalyxCompleter`, an intelligent autocompletion engine for Falyx CLI
|
|
||||||
menus using Prompt Toolkit.
|
|
||||||
|
|
||||||
This completer supports:
|
This module defines `FalyxCompleter`, the interactive completion layer used by
|
||||||
- Command key and alias completion (e.g. `R`, `HELP`, `X`)
|
Falyx menu and prompt-driven CLI sessions. The completer is routing-aware: it
|
||||||
- Argument flag completion for registered commands (e.g. `--tag`, `--name`)
|
delegates namespace traversal to `Falyx.resolve_completion_route()` and only
|
||||||
- Context-aware suggestions based on cursor position and argument structure
|
hands control to a command's `CommandArgumentParser` after a leaf command has
|
||||||
- Interactive value completions (e.g. choices and suggestions defined per argument)
|
been identified.
|
||||||
- File/path-friendly behavior, quoting completions with spaces automatically
|
|
||||||
|
|
||||||
|
Completion behavior is split into two phases:
|
||||||
|
|
||||||
Completions are generated from:
|
1. Namespace completion
|
||||||
- Registered commands in `Falyx`
|
While the user is still selecting a command or namespace entry, completion
|
||||||
- Argument metadata and `suggest_next()` from `CommandArgumentParser`
|
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.
|
||||||
|
|
||||||
Integrated with the `Falyx.prompt_session` to enhance the interactive experience.
|
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
|
from __future__ import annotations
|
||||||
@@ -33,130 +43,172 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
class FalyxCompleter(Completer):
|
class FalyxCompleter(Completer):
|
||||||
"""
|
"""Prompt Toolkit completer for routed Falyx input.
|
||||||
Prompt Toolkit completer for Falyx CLI command input.
|
|
||||||
|
|
||||||
This completer provides real-time, context-aware suggestions for:
|
`FalyxCompleter` provides context-aware completions for interactive Falyx
|
||||||
- Command keys and aliases (resolved via Falyx._name_map)
|
sessions. It first asks the owning `Falyx` instance to resolve the current
|
||||||
- CLI argument flags and values for each command
|
input into a partial completion route. Based on that route, it either:
|
||||||
- Suggestions and choices defined in the associated CommandArgumentParser
|
|
||||||
|
|
||||||
It leverages `CommandArgumentParser.suggest_next()` to compute valid completions
|
- suggests visible entries from the active namespace, or
|
||||||
based on current argument state, including:
|
- delegates argument completion to the resolved command's argument parser.
|
||||||
- Remaining required or optional flags
|
|
||||||
- Flag value suggestions (choices or custom completions)
|
This keeps completion aligned with Falyx's routing model so nested
|
||||||
- Next positional argument hints
|
namespaces, preview-prefixed commands, and command-local argument parsing
|
||||||
- Inserts longest common prefix (LCP) completions when applicable
|
all behave consistently with actual execution.
|
||||||
- Handles special cases like quoted strings and spaces
|
|
||||||
- Supports dynamic argument suggestions (e.g. flags, file paths, etc.)
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
falyx (Falyx): The active Falyx instance providing command and parser context.
|
falyx (Falyx): Active Falyx application instance used to resolve routes
|
||||||
|
and retrieve completion candidates.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, falyx: "Falyx"):
|
def __init__(self, falyx: Falyx):
|
||||||
|
"""Initialize the completer with a bound Falyx instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
falyx (Falyx): Active Falyx application that owns the routing and
|
||||||
|
command metadata used for completion.
|
||||||
|
"""
|
||||||
self.falyx = falyx
|
self.falyx = falyx
|
||||||
|
|
||||||
def get_completions(self, document: Document, complete_event) -> Iterable[Completion]:
|
def get_completions(self, document: Document, complete_event):
|
||||||
"""
|
"""Yield completions for the current input buffer.
|
||||||
Compute completions for the current user input.
|
|
||||||
|
|
||||||
Analyzes the input buffer, determines whether the user is typing:
|
This method is the main Prompt Toolkit completion entrypoint. It parses
|
||||||
• A command key/alias
|
the text before the cursor, determines whether the user is still routing
|
||||||
• A flag/option
|
through namespaces or has already reached a leaf command, and then
|
||||||
• An argument value
|
yields matching `Completion` objects.
|
||||||
|
|
||||||
and yields appropriate completions.
|
Behavior:
|
||||||
|
- Splits the current input using `shlex.split()`.
|
||||||
|
- Detects preview-mode input prefixed with `?`.
|
||||||
|
- Separates committed tokens from the active stub under the cursor.
|
||||||
|
- Resolves the partial route through `Falyx.resolve_completion_route()`.
|
||||||
|
- Suggests namespace entries and namespace help flags while routing.
|
||||||
|
- Delegates leaf-command completion to
|
||||||
|
`CommandArgumentParser.suggest_next()` once a command is resolved.
|
||||||
|
- Preserves shell-safe quoting for suggestions containing spaces.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
document (Document): The current Prompt Toolkit document (input buffer & cursor).
|
document (Document): Prompt Toolkit document representing the current
|
||||||
complete_event: The triggering event (TAB key, menu display, etc.) — not used here.
|
input buffer and cursor position.
|
||||||
|
complete_event: Prompt Toolkit completion event metadata. It is not
|
||||||
|
currently inspected directly.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Completion: One or more completions matching the current stub text.
|
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
|
text = document.text_before_cursor
|
||||||
try:
|
try:
|
||||||
tokens = shlex.split(text)
|
tokens = shlex.split(text)
|
||||||
cursor_at_end_of_token = document.text_before_cursor.endswith((" ", "\t"))
|
cursor_at_end = text.endswith((" ", "\t"))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return
|
return
|
||||||
|
|
||||||
if not tokens or (len(tokens) == 1 and not cursor_at_end_of_token):
|
is_preview = False
|
||||||
# Suggest command keys and aliases
|
if tokens and tokens[0].startswith("?"):
|
||||||
stub = tokens[0] if tokens else ""
|
is_preview = True
|
||||||
suggestions = [c.text for c in self._suggest_commands(stub)]
|
tokens[0] = tokens[0][1:]
|
||||||
yield from self._yield_lcp_completions(suggestions, stub)
|
|
||||||
|
if cursor_at_end:
|
||||||
|
committed_tokens = tokens
|
||||||
|
stub = ""
|
||||||
|
else:
|
||||||
|
committed_tokens = tokens[:-1] if tokens else []
|
||||||
|
stub = tokens[-1] if tokens else ""
|
||||||
|
|
||||||
|
context = self.falyx.get_current_invocation_context().model_copy(
|
||||||
|
update={"is_preview": is_preview}
|
||||||
|
)
|
||||||
|
|
||||||
|
route = self.falyx.resolve_completion_route(
|
||||||
|
committed_tokens,
|
||||||
|
stub=stub,
|
||||||
|
cursor_at_end_of_token=cursor_at_end,
|
||||||
|
invocation_context=context,
|
||||||
|
is_preview=is_preview,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Still selecting an entry in the current namespace
|
||||||
|
if route.expecting_entry:
|
||||||
|
suggestions = self._suggest_namespace_entries(route.namespace, route.stub)
|
||||||
|
|
||||||
|
# Only here should namespace-level help/TLDR be suggested.
|
||||||
|
# TODO: better completer in FalyxParser
|
||||||
|
if not route.command: # and (not route.stub or route.stub.startswith("-")):
|
||||||
|
for flag in route.namespace.parser._options_by_dest:
|
||||||
|
if flag.startswith(route.stub):
|
||||||
|
suggestions.append(flag)
|
||||||
|
|
||||||
|
if route.is_preview:
|
||||||
|
suggestions = [f"?{s}" for s in suggestions]
|
||||||
|
current_stub = f"?{route.stub}" if route.stub else "?"
|
||||||
|
else:
|
||||||
|
current_stub = route.stub
|
||||||
|
|
||||||
|
yield from self._yield_lcp_completions(suggestions, current_stub)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Identify command
|
# Leaf command: CAP owns the rest
|
||||||
command_key = tokens[0].upper()
|
if not route.command or not route.command.arg_parser:
|
||||||
command = self.falyx._name_map.get(command_key)
|
|
||||||
if not command or not command.arg_parser:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# If at end of token, e.g., "--t" vs "--tag ", add a stub so suggest_next sees it
|
leaf_tokens = list(route.leaf_argv)
|
||||||
parsed_args = tokens[1:] if cursor_at_end_of_token else tokens[1:-1]
|
if route.stub:
|
||||||
stub = "" if cursor_at_end_of_token else tokens[-1]
|
leaf_tokens.append(route.stub)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
suggestions = command.arg_parser.suggest_next(
|
suggestions = route.command.arg_parser.suggest_next(
|
||||||
parsed_args + ([stub] if stub else []), cursor_at_end_of_token
|
leaf_tokens,
|
||||||
|
route.cursor_at_end_of_token,
|
||||||
)
|
)
|
||||||
yield from self._yield_lcp_completions(suggestions, stub)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return
|
return
|
||||||
|
|
||||||
def _suggest_commands(self, prefix: str) -> Iterable[Completion]:
|
yield from self._yield_lcp_completions(suggestions, route.stub)
|
||||||
"""
|
|
||||||
Suggest top-level command keys and aliases based on the given prefix.
|
|
||||||
|
|
||||||
Filters all known commands (and `exit`, `help`, `history` built-ins)
|
def _suggest_namespace_entries(self, namespace: Falyx, prefix: str) -> list[str]:
|
||||||
to only those starting with the given prefix.
|
"""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:
|
Args:
|
||||||
prefix (str): The current typed prefix.
|
namespace (Falyx): Namespace whose entries should be searched for
|
||||||
|
completion candidates.
|
||||||
Yields:
|
prefix (str): Current partially typed entry name.
|
||||||
Completion: Matching keys or aliases from all registered commands.
|
|
||||||
"""
|
|
||||||
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.upper(), start_position=-len(prefix))
|
|
||||||
elif key.lower().startswith(prefix):
|
|
||||||
yield Completion(key.lower(), start_position=-len(prefix))
|
|
||||||
|
|
||||||
def _ensure_quote(self, text: str) -> str:
|
|
||||||
"""
|
|
||||||
Ensure that a suggestion is shell-safe by quoting if needed.
|
|
||||||
|
|
||||||
Adds quotes around completions containing whitespace so they can
|
|
||||||
be inserted into the CLI without breaking tokenization.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text (str): The input text to quote.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: The quoted text, suitable for shell command usage.
|
list[str]: Matching namespace entry keys and aliases.
|
||||||
|
"""
|
||||||
|
results: list[str] = []
|
||||||
|
for name in namespace.completion_names:
|
||||||
|
if name.upper().startswith(prefix.upper()):
|
||||||
|
results.append(name.lower() if prefix.islower() else name)
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _ensure_quote(self, text: str) -> str:
|
||||||
|
"""Quote a completion candidate when it contains whitespace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text (str): Raw completion candidate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Shell-safe candidate wrapped in double quotes when needed.
|
||||||
"""
|
"""
|
||||||
if " " in text or "\t" in text:
|
if " " in text or "\t" in text:
|
||||||
return f'"{text}"'
|
return f'"{text}"'
|
||||||
return text
|
return text
|
||||||
|
|
||||||
def _yield_lcp_completions(self, suggestions, stub):
|
def _yield_lcp_completions(self, suggestions, stub) -> Iterable[Completion]:
|
||||||
"""
|
"""Yield completions for the current stub using longest-common-prefix logic.
|
||||||
Yield completions for the current stub using longest-common-prefix logic.
|
|
||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
- If only one match → yield it fully.
|
- If only one match → yield it fully.
|
||||||
@@ -171,26 +223,35 @@ class FalyxCompleter(Completer):
|
|||||||
Yields:
|
Yields:
|
||||||
Completion: Completion objects for the Prompt Toolkit menu.
|
Completion: Completion objects for the Prompt Toolkit menu.
|
||||||
"""
|
"""
|
||||||
matches = [s for s in suggestions if s.startswith(stub)]
|
|
||||||
|
if not suggestions:
|
||||||
|
return
|
||||||
|
|
||||||
|
matches = list(dict.fromkeys(s for s in suggestions if s.startswith(stub)))
|
||||||
if not matches:
|
if not matches:
|
||||||
return
|
return
|
||||||
|
|
||||||
lcp = os.path.commonprefix(matches)
|
lcp = os.path.commonprefix(matches)
|
||||||
|
|
||||||
if len(matches) == 1:
|
if len(matches) == 1:
|
||||||
|
match = matches[0]
|
||||||
yield Completion(
|
yield Completion(
|
||||||
self._ensure_quote(matches[0]),
|
self._ensure_quote(match),
|
||||||
start_position=-len(stub),
|
start_position=-len(stub),
|
||||||
display=matches[0],
|
display=match,
|
||||||
)
|
)
|
||||||
elif len(lcp) > len(stub) and not lcp.startswith("-"):
|
return
|
||||||
yield Completion(lcp, start_position=-len(stub), display=lcp)
|
|
||||||
|
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:
|
for match in matches:
|
||||||
yield Completion(
|
yield Completion(
|
||||||
self._ensure_quote(match), start_position=-len(stub), display=match
|
self._ensure_quote(match),
|
||||||
)
|
start_position=-len(stub),
|
||||||
else:
|
display=match,
|
||||||
for match in matches:
|
|
||||||
yield Completion(
|
|
||||||
self._ensure_quote(match), start_position=-len(stub), display=match
|
|
||||||
)
|
)
|
||||||
|
|||||||
87
falyx/completer_types.py
Normal file
87
falyx/completer_types.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
|
"""Completion route models for routed Falyx autocompletion.
|
||||||
|
|
||||||
|
This module defines `CompletionRoute`, a lightweight value object used by the
|
||||||
|
Falyx completion system to describe the partially resolved state of interactive
|
||||||
|
input during autocompletion.
|
||||||
|
|
||||||
|
`CompletionRoute` sits at the boundary between namespace routing and
|
||||||
|
command-local argument completion. It captures enough information for the
|
||||||
|
completer to determine whether it should continue suggesting namespace entries
|
||||||
|
or delegate to a resolved command's argument parser.
|
||||||
|
|
||||||
|
Typical usage:
|
||||||
|
- A user types part of a namespace path or command key.
|
||||||
|
- Falyx resolves as much of that path as possible.
|
||||||
|
- The resulting `CompletionRoute` describes the active namespace, any
|
||||||
|
resolved leaf command, the remaining argv fragment, and the current
|
||||||
|
token stub under the cursor.
|
||||||
|
- `FalyxCompleter` uses this information to decide what completions to
|
||||||
|
surface next.
|
||||||
|
|
||||||
|
This module is intentionally small and focused. It does not perform routing or
|
||||||
|
completion itself; it only models the routed state needed by the completer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from falyx.context import InvocationContext
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from falyx.command import Command
|
||||||
|
from falyx.falyx import Falyx
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class CompletionRoute:
|
||||||
|
"""Represents a partially resolved route used during autocompletion.
|
||||||
|
|
||||||
|
A `CompletionRoute` describes the current routed state of user input while
|
||||||
|
Falyx is generating interactive completions. It distinguishes between two
|
||||||
|
broad states:
|
||||||
|
|
||||||
|
- namespace-routing state, where the user is still selecting a visible entry
|
||||||
|
within the current namespace
|
||||||
|
- leaf-command state, where a concrete command has been resolved and the
|
||||||
|
remaining input should be completed by that command's argument parser
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
namespace (Falyx): The active namespace in which completion is currently
|
||||||
|
taking place.
|
||||||
|
context (InvocationContext): Invocation-path context used to preserve the
|
||||||
|
routed command path and render context-aware help or usage text.
|
||||||
|
command (Command | None): The resolved leaf command, if routing has
|
||||||
|
already reached a concrete command. Remains `None` while the user is
|
||||||
|
still navigating namespaces.
|
||||||
|
leaf_argv (list[str]): Remaining command-local argv tokens that belong to
|
||||||
|
the resolved leaf command. These are typically passed to the
|
||||||
|
command's argument parser for completion.
|
||||||
|
stub (str): The current token fragment under the cursor. This is the
|
||||||
|
partial text that completion candidates should replace or extend.
|
||||||
|
cursor_at_end_of_token (bool): Whether the cursor is positioned at the
|
||||||
|
end of a completed token boundary, such as immediately after a
|
||||||
|
trailing space.
|
||||||
|
expecting_entry (bool): Whether completion should suggest namespace
|
||||||
|
entries rather than command-local arguments.
|
||||||
|
is_preview (bool): Whether the input is in preview mode, such as when
|
||||||
|
the user begins the invocation with `?`.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- This model is completion-only and is intentionally separate from
|
||||||
|
full execution routing types such as `RouteResult`.
|
||||||
|
- `CompletionRoute` does not validate or parse command arguments; it
|
||||||
|
only records the routed state needed to decide what should complete
|
||||||
|
next.
|
||||||
|
"""
|
||||||
|
|
||||||
|
namespace: Falyx
|
||||||
|
context: InvocationContext
|
||||||
|
command: Command | None = None
|
||||||
|
leaf_argv: list[str] = field(default_factory=list)
|
||||||
|
stub: str = ""
|
||||||
|
cursor_at_end_of_token: bool = False
|
||||||
|
expecting_entry: bool = False
|
||||||
|
is_preview: bool = False
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Configuration loader and schema definitions for the Falyx CLI framework.
|
||||||
Configuration loader and schema definitions for the Falyx CLI framework.
|
|
||||||
|
|
||||||
This module supports config-driven initialization of CLI commands and submenus
|
This module supports config-driven initialization of CLI commands and submenus
|
||||||
from YAML or TOML files. It enables declarative command definitions, auto-imports
|
from YAML or TOML files. It enables declarative command definitions, auto-imports
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
# 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."""
|
"""Global console instance for Falyx CLI applications."""
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
from falyx.themes import get_nord_theme
|
from falyx.themes import OneColors, get_nord_theme
|
||||||
|
|
||||||
console = Console(color_system="truecolor", theme=get_nord_theme())
|
console = Console(color_system="truecolor", theme=get_nord_theme())
|
||||||
|
error_console = Console(color_system="truecolor", theme=get_nord_theme(), stderr=True)
|
||||||
|
|
||||||
|
|
||||||
|
def print_error(
|
||||||
|
message: str | Exception,
|
||||||
|
*,
|
||||||
|
hint: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
error_console.print(f"[{OneColors.DARK_RED}]error:[/] {message}")
|
||||||
|
if hint:
|
||||||
|
error_console.print(f"[{OneColors.LIGHT_YELLOW}]hint:[/] {hint}")
|
||||||
|
|||||||
188
falyx/context.py
188
falyx/context.py
@@ -1,19 +1,22 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Context models for Falyx execution and invocation state.
|
||||||
Execution context management for Falyx CLI actions.
|
|
||||||
|
|
||||||
This module defines `ExecutionContext` and `SharedContext`, which are responsible for
|
This module defines the core context objects used throughout Falyx to track both
|
||||||
capturing per-action and cross-action metadata during CLI workflow execution. These
|
runtime execution metadata and routed invocation-path state.
|
||||||
context objects provide structured introspection, result tracking, error recording,
|
|
||||||
and time-based performance metrics.
|
|
||||||
|
|
||||||
- `ExecutionContext`: Captures runtime information for a single action execution,
|
It provides:
|
||||||
including arguments, results, exceptions, timing, and logging.
|
- `ExecutionContext` for per-action execution details such as arguments,
|
||||||
- `SharedContext`: Maintains shared state and result propagation across
|
results, exceptions, timing, and summary logging.
|
||||||
`ChainedAction` or `ActionGroup` executions.
|
- `SharedContext` for transient shared state across grouped or chained
|
||||||
|
actions, including propagated results, indexed errors, and arbitrary
|
||||||
|
shared data.
|
||||||
|
- `InvocationContext` for capturing the current routed command path as an
|
||||||
|
immutable value object that supports both plain-text and Rich-markup
|
||||||
|
rendering.
|
||||||
|
|
||||||
These contexts enable rich introspection, traceability, and workflow coordination,
|
Together, these models support Falyx lifecycle hooks, execution tracing,
|
||||||
supporting hook lifecycles, retries, and structured output generation.
|
history/introspection, and context-aware help and usage rendering across CLI
|
||||||
|
and menu modes.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -24,8 +27,12 @@ from typing import Any
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
from rich.markup import escape
|
||||||
|
from rich.style import Style
|
||||||
|
|
||||||
from falyx.console import console
|
from falyx.console import console
|
||||||
|
from falyx.display_types import StyledSegment
|
||||||
|
from falyx.mode import FalyxMode
|
||||||
|
|
||||||
|
|
||||||
class ExecutionContext(BaseModel):
|
class ExecutionContext(BaseModel):
|
||||||
@@ -222,9 +229,9 @@ class SharedContext(BaseModel):
|
|||||||
results (list[Any]): Captures results from each action, in order of execution.
|
results (list[Any]): Captures results from each action, in order of execution.
|
||||||
errors (list[tuple[int, BaseException]]): Indexed list of errors from failed actions.
|
errors (list[tuple[int, BaseException]]): Indexed list of errors from failed actions.
|
||||||
current_index (int): Index of the currently executing action (used in chains).
|
current_index (int): Index of the currently executing action (used in chains).
|
||||||
is_parallel (bool): Whether the context is used in parallel mode (ActionGroup).
|
is_concurrent (bool): Whether the context is used in concurrent mode (ActionGroup).
|
||||||
shared_result (Any | None): Optional shared value available to all actions in
|
shared_result (Any | None): Optional shared value available to all actions in
|
||||||
parallel mode.
|
concurrent mode.
|
||||||
share (dict[str, Any]): Custom shared key-value store for user-defined
|
share (dict[str, Any]): Custom shared key-value store for user-defined
|
||||||
communication
|
communication
|
||||||
between actions (e.g., flags, intermediate data, settings).
|
between actions (e.g., flags, intermediate data, settings).
|
||||||
@@ -247,7 +254,7 @@ class SharedContext(BaseModel):
|
|||||||
results: list[Any] = Field(default_factory=list)
|
results: list[Any] = Field(default_factory=list)
|
||||||
errors: list[tuple[int, BaseException]] = Field(default_factory=list)
|
errors: list[tuple[int, BaseException]] = Field(default_factory=list)
|
||||||
current_index: int = -1
|
current_index: int = -1
|
||||||
is_parallel: bool = False
|
is_concurrent: bool = False
|
||||||
shared_result: Any | None = None
|
shared_result: Any | None = None
|
||||||
|
|
||||||
share: dict[str, Any] = Field(default_factory=dict)
|
share: dict[str, Any] = Field(default_factory=dict)
|
||||||
@@ -262,11 +269,11 @@ class SharedContext(BaseModel):
|
|||||||
|
|
||||||
def set_shared_result(self, result: Any) -> None:
|
def set_shared_result(self, result: Any) -> None:
|
||||||
self.shared_result = result
|
self.shared_result = result
|
||||||
if self.is_parallel:
|
if self.is_concurrent:
|
||||||
self.results.append(result)
|
self.results.append(result)
|
||||||
|
|
||||||
def last_result(self) -> Any:
|
def last_result(self) -> Any:
|
||||||
if self.is_parallel:
|
if self.is_concurrent:
|
||||||
return self.shared_result
|
return self.shared_result
|
||||||
return self.results[-1] if self.results else None
|
return self.results[-1] if self.results else None
|
||||||
|
|
||||||
@@ -277,14 +284,155 @@ class SharedContext(BaseModel):
|
|||||||
self.share[key] = value
|
self.share[key] = value
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
parallel_label = "Parallel" if self.is_parallel else "Sequential"
|
concurrent_label = "Concurrent" if self.is_concurrent else "Sequential"
|
||||||
return (
|
return (
|
||||||
f"<{parallel_label}SharedContext '{self.name}' | "
|
f"<{concurrent_label}SharedContext '{self.name}' | "
|
||||||
f"Results: {self.results} | "
|
f"Results: {self.results} | "
|
||||||
f"Errors: {self.errors}>"
|
f"Errors: {self.errors}>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InvocationContext(BaseModel):
|
||||||
|
"""Immutable invocation-path context for routed Falyx help and execution.
|
||||||
|
|
||||||
|
`InvocationContext` captures the current displayable command path as the router
|
||||||
|
descends through namespaces and commands. It stores both the raw typed path
|
||||||
|
(`typed_path`) and a styled segment representation (`segments`) so the same
|
||||||
|
context can be rendered as plain text or Rich markup.
|
||||||
|
|
||||||
|
This model is intended to be treated as an immutable value object. Methods such
|
||||||
|
as `with_path_segment()` and `without_last_path_segment()` return new context
|
||||||
|
instances rather than mutating the existing one.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
program (str): Root program name used in CLI-mode help and usage output.
|
||||||
|
program_style (Style | str): Rich style applied to the program name when rendering
|
||||||
|
`markup_path`.
|
||||||
|
typed_path (list[str]): Raw invocation tokens collected during routing,
|
||||||
|
excluding the root program name.
|
||||||
|
segments (list[StyledSegment]): Styled path segments used to render the
|
||||||
|
invocation path with Rich markup.
|
||||||
|
mode (FalyxMode): Active Falyx mode for this invocation context. This is
|
||||||
|
used to determine whether the path should include the program name.
|
||||||
|
is_preview (bool): Whether the current invocation is a preview flow rather
|
||||||
|
than a normal execution flow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
program: str = ""
|
||||||
|
program_style: Style | str = ""
|
||||||
|
typed_path: list[str] = Field(default_factory=list)
|
||||||
|
segments: list[StyledSegment] = Field(default_factory=list)
|
||||||
|
mode: FalyxMode = FalyxMode.MENU
|
||||||
|
is_preview: bool = False
|
||||||
|
|
||||||
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_cli_mode(self) -> bool:
|
||||||
|
"""Whether this context should render using CLI path semantics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: `True` when the invocation is not in menu mode, meaning rendered
|
||||||
|
paths should include the program name. `False` when in menu mode.
|
||||||
|
"""
|
||||||
|
return self.mode != FalyxMode.MENU
|
||||||
|
|
||||||
|
def with_path_segment(
|
||||||
|
self,
|
||||||
|
token: str,
|
||||||
|
*,
|
||||||
|
style: Style | str | None = None,
|
||||||
|
) -> InvocationContext:
|
||||||
|
"""Return a new context with one additional path segment appended.
|
||||||
|
|
||||||
|
This method preserves the current context and creates a new
|
||||||
|
`InvocationContext` with the provided token added to both `typed_path` and
|
||||||
|
`segments`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token (str): Raw path token to append, such as a namespace key,
|
||||||
|
command key, or alias.
|
||||||
|
style (str | None): Optional Rich style for the appended segment.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
InvocationContext: A new context containing the appended path segment.
|
||||||
|
"""
|
||||||
|
return InvocationContext(
|
||||||
|
program=self.program,
|
||||||
|
program_style=self.program_style,
|
||||||
|
typed_path=[*self.typed_path, token],
|
||||||
|
segments=[*self.segments, StyledSegment(text=token, style=style)],
|
||||||
|
mode=self.mode,
|
||||||
|
is_preview=self.is_preview,
|
||||||
|
)
|
||||||
|
|
||||||
|
def without_last_path_segment(self) -> InvocationContext:
|
||||||
|
"""Return a new context with the last path segment removed.
|
||||||
|
|
||||||
|
This method preserves the current context and creates a new
|
||||||
|
`InvocationContext` with the last token removed from both `typed_path` and
|
||||||
|
`segments`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
InvocationContext: A new context with the last path segment removed, or the
|
||||||
|
current context if no path segments are present.
|
||||||
|
"""
|
||||||
|
if not self.typed_path:
|
||||||
|
return self
|
||||||
|
return InvocationContext(
|
||||||
|
program=self.program,
|
||||||
|
program_style=self.program_style,
|
||||||
|
typed_path=self.typed_path[:-1],
|
||||||
|
segments=self.segments[:-1],
|
||||||
|
mode=self.mode,
|
||||||
|
is_preview=self.is_preview,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def plain_path(self) -> str:
|
||||||
|
"""Render the invocation path as plain text.
|
||||||
|
|
||||||
|
In CLI mode, the rendered path includes the root program name followed by
|
||||||
|
all collected path segments. In menu mode, only the collected path segments
|
||||||
|
are rendered.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Plain-text invocation path suitable for logs, comparisons, or
|
||||||
|
non-styled help output.
|
||||||
|
"""
|
||||||
|
parts = [seg.text for seg in self.segments]
|
||||||
|
if self.is_cli_mode:
|
||||||
|
return " ".join([self.program, *parts]).strip()
|
||||||
|
return " ".join(parts).strip()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def markup_path(self) -> str:
|
||||||
|
"""Render the invocation path as escaped Rich markup.
|
||||||
|
|
||||||
|
In CLI mode, the root program name is included and styled with
|
||||||
|
`program_style` when provided. Each path segment is escaped and styled
|
||||||
|
using its associated `StyledSegment.style` value when present.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Rich-markup invocation path suitable for help and usage rendering.
|
||||||
|
"""
|
||||||
|
parts: list[str] = []
|
||||||
|
if self.is_cli_mode and self.program:
|
||||||
|
if self.program_style:
|
||||||
|
parts.append(
|
||||||
|
f"[{self.program_style}]{escape(self.program)}[/{self.program_style}]"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
parts.append(escape(self.program))
|
||||||
|
|
||||||
|
for seg in self.segments:
|
||||||
|
if seg.style:
|
||||||
|
parts.append(f"[{seg.style}]{escape(seg.text)}[/{seg.style}]")
|
||||||
|
else:
|
||||||
|
parts.append(escape(seg.text))
|
||||||
|
return " ".join(parts).strip()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Provides debug logging hooks for Falyx action execution.
|
||||||
Provides debug logging hooks for Falyx action execution.
|
|
||||||
|
|
||||||
This module defines lifecycle hook functions (`log_before`, `log_success`, `log_after`, `log_error`)
|
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.
|
that can be registered with a `HookManager` to trace command execution.
|
||||||
|
|||||||
33
falyx/display_types.py
Normal file
33
falyx/display_types.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
|
"""Display types for Falyx.
|
||||||
|
|
||||||
|
This module defines data models used for representing styled display elements in
|
||||||
|
Falyx's CLI output, such as command paths, namespaces, and TLDR examples. These
|
||||||
|
models are designed to be simple containers for the raw text and styling
|
||||||
|
information needed to render consistent and visually appealing CLI interfaces using
|
||||||
|
the Rich library.
|
||||||
|
|
||||||
|
It provides:
|
||||||
|
- `StyledSegment` for representing a single styled token.
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from rich.style import Style
|
||||||
|
|
||||||
|
|
||||||
|
class StyledSegment(BaseModel):
|
||||||
|
"""Styled path segment used to build Rich styled markup.
|
||||||
|
|
||||||
|
`StyledSegment` represents a single token. It stores the raw display
|
||||||
|
text and an optional Rich style so text can be rendered either
|
||||||
|
as plain text or styled markup.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
text (str): Display text for this path segment.
|
||||||
|
style (str | None): Optional Rich style applied when rendering this
|
||||||
|
segment in markup output.
|
||||||
|
"""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
style: Style | str | None = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Defines all custom exception classes used in the Falyx CLI framework.
|
||||||
Defines all custom exception classes used in the Falyx CLI framework.
|
|
||||||
|
|
||||||
These exceptions provide structured error handling for common failure cases,
|
These exceptions provide structured error handling for common failure cases,
|
||||||
including command conflicts, invalid actions or hooks, parser errors, and execution guards
|
including command conflicts, invalid actions or hooks, parser errors, and execution guards
|
||||||
@@ -18,7 +17,8 @@ Exception Hierarchy:
|
|||||||
├── EmptyChainError
|
├── EmptyChainError
|
||||||
├── EmptyGroupError
|
├── EmptyGroupError
|
||||||
├── EmptyPoolError
|
├── EmptyPoolError
|
||||||
└── CommandArgumentError
|
├── CommandArgumentError
|
||||||
|
└── EntryNotFoundError
|
||||||
|
|
||||||
These are raised internally throughout the Falyx system to signal user-facing or
|
These are raised internally throughout the Falyx system to signal user-facing or
|
||||||
developer-facing problems that should be caught and reported.
|
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):
|
class FalyxError(Exception):
|
||||||
"""Custom exception for the Menu class."""
|
"""Base exception class for all Falyx CLI framework errors."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str | None = None,
|
||||||
|
hint: str | None = None,
|
||||||
|
):
|
||||||
|
if message:
|
||||||
|
super().__init__(message)
|
||||||
|
self.hint = hint
|
||||||
|
|
||||||
|
|
||||||
class CommandAlreadyExistsError(FalyxError):
|
class CommandAlreadyExistsError(FalyxError):
|
||||||
"""Exception raised when an command with the same key already exists in the menu."""
|
"""Exception raised when an command with the same key already exists in the Falyx instance."""
|
||||||
|
|
||||||
|
|
||||||
class InvalidHookError(FalyxError):
|
class InvalidHookError(FalyxError):
|
||||||
@@ -42,7 +51,7 @@ class InvalidActionError(FalyxError):
|
|||||||
|
|
||||||
|
|
||||||
class NotAFalyxError(FalyxError):
|
class NotAFalyxError(FalyxError):
|
||||||
"""Exception raised when the provided submenu is not an instance of Menu."""
|
"""Exception raised when the provided object is not an instance of a Falyx class."""
|
||||||
|
|
||||||
|
|
||||||
class CircuitBreakerOpen(FalyxError):
|
class CircuitBreakerOpen(FalyxError):
|
||||||
@@ -54,12 +63,159 @@ class EmptyChainError(FalyxError):
|
|||||||
|
|
||||||
|
|
||||||
class EmptyGroupError(FalyxError):
|
class EmptyGroupError(FalyxError):
|
||||||
"""Exception raised when the chain is empty."""
|
"""Exception raised when the group is empty."""
|
||||||
|
|
||||||
|
|
||||||
class EmptyPoolError(FalyxError):
|
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."""
|
"""Exception raised when there is an error in the command argument parser."""
|
||||||
|
|
||||||
|
|
||||||
|
class ArgumentGroupError(CommandArgumentError):
|
||||||
|
"""Exception raised when there is an error in the argument group."""
|
||||||
|
|
||||||
|
|
||||||
|
class ArgumentParsingError(CommandArgumentError):
|
||||||
|
"""Exception raised when there is an error during argument parsing."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str | None = None,
|
||||||
|
hint: str | None = None,
|
||||||
|
show_short_usage: bool = True,
|
||||||
|
command_key: str | None = None,
|
||||||
|
dest: str | None = None,
|
||||||
|
token: str | None = None,
|
||||||
|
):
|
||||||
|
self.command_key = command_key
|
||||||
|
self.dest = dest
|
||||||
|
self.token = token
|
||||||
|
super().__init__(message, hint, show_short_usage)
|
||||||
|
|
||||||
|
|
||||||
|
class EntryNotFoundError(UsageError):
|
||||||
|
"""Exception raised when a routing entry is not found."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
unknown_name: str,
|
||||||
|
suggestions: list[str] | None = None,
|
||||||
|
message_context: str = "",
|
||||||
|
show_short_usage: bool = True,
|
||||||
|
):
|
||||||
|
self.unknown_name = unknown_name
|
||||||
|
self.suggestions = suggestions
|
||||||
|
self.message_context = message_context
|
||||||
|
super().__init__(
|
||||||
|
self.build_message(),
|
||||||
|
self.build_hint(),
|
||||||
|
show_short_usage,
|
||||||
|
)
|
||||||
|
|
||||||
|
def build_message(self) -> str:
|
||||||
|
prefix = f"{self.message_context}: " if self.message_context else ""
|
||||||
|
return f"{prefix}unknown command or namespace '{self.unknown_name}'."
|
||||||
|
|
||||||
|
def build_hint(self) -> str | None:
|
||||||
|
if self.suggestions:
|
||||||
|
return f"did you mean: {', '.join(self.suggestions[:10])}?"
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class UnrecognizedOptionError(ArgumentParsingError):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
token: str,
|
||||||
|
remaining_flags: list[str] | None = None,
|
||||||
|
show_short_usage: bool = True,
|
||||||
|
):
|
||||||
|
self.remaining_flags = remaining_flags
|
||||||
|
self.token = token
|
||||||
|
super().__init__(
|
||||||
|
self.build_message(),
|
||||||
|
self.build_hint(),
|
||||||
|
show_short_usage=show_short_usage,
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
|
||||||
|
def build_message(self) -> str:
|
||||||
|
return f"unrecognized option '{self.token}'"
|
||||||
|
|
||||||
|
def build_hint(self) -> str:
|
||||||
|
if self.remaining_flags:
|
||||||
|
return f"did you mean one of: {', '.join(self.remaining_flags)}?"
|
||||||
|
return "use --help to see available options"
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidValueError(ArgumentParsingError):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
dest: str | None = None,
|
||||||
|
choices: list[str] | None = None,
|
||||||
|
expected: str | None = None,
|
||||||
|
error: Exception | str | None = None,
|
||||||
|
show_short_usage: bool = True,
|
||||||
|
):
|
||||||
|
self.choices = choices
|
||||||
|
self.expected = expected
|
||||||
|
self.error = error
|
||||||
|
self.dest = dest
|
||||||
|
super().__init__(
|
||||||
|
self.build_message(),
|
||||||
|
self.build_hint(),
|
||||||
|
show_short_usage=show_short_usage,
|
||||||
|
dest=dest,
|
||||||
|
)
|
||||||
|
|
||||||
|
def build_message(self) -> str:
|
||||||
|
if self.dest and self.choices:
|
||||||
|
return f"invalid value for '{self.dest}'"
|
||||||
|
elif self.dest and self.error:
|
||||||
|
return f"invalid value for '{self.dest}': {self.error}"
|
||||||
|
elif self.dest and self.expected:
|
||||||
|
return f"invalid value for '{self.dest}': expected {self.expected}"
|
||||||
|
else:
|
||||||
|
return "invalid command argument value."
|
||||||
|
|
||||||
|
def build_hint(self) -> str | None:
|
||||||
|
if self.dest and self.choices:
|
||||||
|
return f"the value for '{self.dest}' must be one of {{{', '.join(self.choices)}}}."
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class MissingValueError(ArgumentParsingError):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
dest: str,
|
||||||
|
expected_count: int | None = None,
|
||||||
|
actual_count: int | None = None,
|
||||||
|
):
|
||||||
|
self.expected_count = expected_count
|
||||||
|
self.actual_count = actual_count
|
||||||
|
self.dest = dest
|
||||||
|
|
||||||
|
|
||||||
|
class TokenizationError(UsageError):
|
||||||
|
raw_input: str | None = None
|
||||||
|
|||||||
61
falyx/execution_option.py
Normal file
61
falyx/execution_option.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
|
"""Execution option enums for the Falyx command runtime.
|
||||||
|
|
||||||
|
This module defines `ExecutionOption`, the enum used to represent optional
|
||||||
|
execution-scoped behaviors that a command may choose to expose through its
|
||||||
|
argument parser.
|
||||||
|
|
||||||
|
Execution options are distinct from normal command inputs. They control runtime
|
||||||
|
behavior around command execution rather than the business-logic arguments
|
||||||
|
passed to the underlying action. Typical examples include summary output,
|
||||||
|
retry configuration, and confirmation handling.
|
||||||
|
|
||||||
|
`ExecutionOption` is used by Falyx components such as `Command` and
|
||||||
|
`CommandArgumentParser` to declaratively enable execution-level flags and to
|
||||||
|
normalize user- or config-provided option names into a validated enum value.
|
||||||
|
|
||||||
|
The enum also implements custom missing-value handling so string inputs can be
|
||||||
|
resolved case-insensitively with helpful error messages.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionOption(Enum):
|
||||||
|
"""Enumerates optional execution-scoped behaviors supported by Falyx.
|
||||||
|
|
||||||
|
`ExecutionOption` identifies runtime features that can be enabled on a
|
||||||
|
command independently of its normal argument schema. When present, these
|
||||||
|
options typically cause `CommandArgumentParser` to expose additional flags
|
||||||
|
that affect how the command is executed rather than what the command does.
|
||||||
|
|
||||||
|
Supported options:
|
||||||
|
SUMMARY: Enable summary-related execution flags and reporting behavior.
|
||||||
|
RETRY: Enable retry-related execution flags such as retry count, delay,
|
||||||
|
and backoff.
|
||||||
|
CONFIRM: Enable confirmation-related execution flags such as forcing or
|
||||||
|
skipping confirmation prompts.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- These values are intended for execution control, not domain-specific
|
||||||
|
command input.
|
||||||
|
- String values are normalized case-insensitively through `_missing_()`
|
||||||
|
so config and user input can be converted into enum members with
|
||||||
|
friendlier validation behavior.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SUMMARY = "summary"
|
||||||
|
RETRY = "retry"
|
||||||
|
CONFIRM = "confirm"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _missing_(cls, value: object) -> ExecutionOption:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(f"Invalid {cls.__name__}: {value!r}")
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
for member in cls:
|
||||||
|
if member.value == normalized:
|
||||||
|
return member
|
||||||
|
valid = ", ".join(member.value for member in cls)
|
||||||
|
raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}")
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Provides the `ExecutionRegistry`, a centralized runtime store for capturing and
|
||||||
Provides the `ExecutionRegistry`, a centralized runtime store for capturing and inspecting
|
inspecting the execution history of Falyx actions.
|
||||||
the execution history of Falyx actions.
|
|
||||||
|
|
||||||
The registry automatically records every `ExecutionContext` created during action
|
The registry automatically records every `ExecutionContext` created during action
|
||||||
execution—including context metadata, results, exceptions, duration, and tracebacks.
|
execution—including context metadata, results, exceptions, duration, and tracebacks.
|
||||||
@@ -63,8 +62,7 @@ from falyx.themes import OneColors
|
|||||||
|
|
||||||
|
|
||||||
class ExecutionRegistry:
|
class ExecutionRegistry:
|
||||||
"""
|
"""Global registry for recording and inspecting Falyx action executions.
|
||||||
Global registry for recording and inspecting Falyx action executions.
|
|
||||||
|
|
||||||
This class captures every `ExecutionContext` created by Falyx Actions,
|
This class captures every `ExecutionContext` created by Falyx Actions,
|
||||||
tracking metadata, results, exceptions, and performance metrics. It enables
|
tracking metadata, results, exceptions, and performance metrics. It enables
|
||||||
@@ -96,8 +94,7 @@ class ExecutionRegistry:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def record(cls, context: ExecutionContext):
|
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,
|
This method logs the context, appends it to the registry,
|
||||||
and makes it available for future summary or filtering.
|
and makes it available for future summary or filtering.
|
||||||
@@ -115,8 +112,7 @@ class ExecutionRegistry:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_all(cls) -> list[ExecutionContext]:
|
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:
|
Returns:
|
||||||
list[ExecutionContext]: All stored action contexts.
|
list[ExecutionContext]: All stored action contexts.
|
||||||
@@ -125,8 +121,7 @@ class ExecutionRegistry:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_by_name(cls, name: str) -> list[ExecutionContext]:
|
def get_by_name(cls, name: str) -> list[ExecutionContext]:
|
||||||
"""
|
"""Return all executions recorded under a given action name.
|
||||||
Retrieve all executions recorded under a given action name.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): The name of the action.
|
name (str): The name of the action.
|
||||||
@@ -138,8 +133,7 @@ class ExecutionRegistry:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_latest(cls) -> ExecutionContext:
|
def get_latest(cls) -> ExecutionContext:
|
||||||
"""
|
"""Return the most recent execution context.
|
||||||
Return the most recent execution context.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ExecutionContext: The last recorded context.
|
ExecutionContext: The last recorded context.
|
||||||
@@ -148,8 +142,7 @@ class ExecutionRegistry:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def clear(cls):
|
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.
|
This operation is destructive and cannot be undone.
|
||||||
"""
|
"""
|
||||||
@@ -167,8 +160,7 @@ class ExecutionRegistry:
|
|||||||
last_result: bool = False,
|
last_result: bool = False,
|
||||||
status: Literal["all", "success", "error"] = "all",
|
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.
|
Supports filtering by action name, index, or execution status.
|
||||||
Can optionally show only the last result or a specific indexed result.
|
Can optionally show only the last result or a specific indexed result.
|
||||||
|
|||||||
2814
falyx/falyx.py
2814
falyx/falyx.py
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Defines the `HookManager` and `HookType` used in the Falyx CLI framework to manage
|
||||||
Defines the `HookManager` and `HookType` used in the Falyx CLI framework to manage
|
|
||||||
execution lifecycle hooks around actions and commands.
|
execution lifecycle hooks around actions and commands.
|
||||||
|
|
||||||
The hook system enables structured callbacks for important stages in a Falyx action's
|
The hook system enables structured callbacks for important stages in a Falyx action's
|
||||||
@@ -31,8 +30,7 @@ Hook = Union[
|
|||||||
|
|
||||||
|
|
||||||
class HookType(Enum):
|
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
|
HookType is used to classify lifecycle events that can be intercepted
|
||||||
with user-defined callbacks.
|
with user-defined callbacks.
|
||||||
@@ -91,8 +89,7 @@ class HookType(Enum):
|
|||||||
|
|
||||||
|
|
||||||
class HookManager:
|
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
|
`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
|
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):
|
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:
|
Args:
|
||||||
hook_type (HookType | str): The hook category (e.g. "before", "on_success").
|
hook_type (HookType | str): The hook category (e.g. "before", "on_success").
|
||||||
@@ -128,8 +124,7 @@ class HookManager:
|
|||||||
self._hooks[hook_type].append(hook)
|
self._hooks[hook_type].append(hook)
|
||||||
|
|
||||||
def clear(self, hook_type: HookType | None = None):
|
def clear(self, hook_type: HookType | None = None):
|
||||||
"""
|
"""Clear registered hooks for one or all hook types.
|
||||||
Clear registered hooks for one or all hook types.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
hook_type (HookType | None): If None, clears all hooks.
|
hook_type (HookType | None): If None, clears all hooks.
|
||||||
@@ -141,8 +136,7 @@ class HookManager:
|
|||||||
self._hooks[ht] = []
|
self._hooks[ht] = []
|
||||||
|
|
||||||
async def trigger(self, hook_type: HookType, context: ExecutionContext):
|
async def trigger(self, hook_type: HookType, context: ExecutionContext):
|
||||||
"""
|
"""Invoke all hooks registered for a given lifecycle phase.
|
||||||
Invoke all hooks registered for a given lifecycle phase.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
hook_type (HookType): The lifecycle phase to trigger.
|
hook_type (HookType): The lifecycle phase to trigger.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Defines reusable lifecycle hooks for Falyx Actions and Commands.
|
||||||
Defines reusable lifecycle hooks for Falyx Actions and Commands.
|
|
||||||
|
|
||||||
This module includes:
|
This module includes:
|
||||||
- `spinner_before_hook`: Automatically starts a spinner before an action runs.
|
- `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):
|
async def spinner_before_hook(context: ExecutionContext):
|
||||||
"""Adds a spinner before the action starts."""
|
"""Adds a spinner before the action starts."""
|
||||||
cmd = context.action
|
command = context.action
|
||||||
if cmd.options_manager is None:
|
if command.options_manager is None:
|
||||||
return
|
return
|
||||||
sm = context.action.options_manager.spinners
|
sm = context.action.options_manager.spinners
|
||||||
if hasattr(cmd, "name"):
|
if hasattr(command, "name"):
|
||||||
cmd_name = cmd.name
|
command_name = command.name
|
||||||
else:
|
else:
|
||||||
cmd_name = cmd.key
|
command_name = command.key
|
||||||
await sm.add(
|
await sm.add(
|
||||||
cmd_name,
|
command_name,
|
||||||
cmd.spinner_message,
|
command.spinner_message,
|
||||||
cmd.spinner_type,
|
command.spinner_type,
|
||||||
cmd.spinner_style,
|
command.spinner_style,
|
||||||
cmd.spinner_speed,
|
command.spinner_speed,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def spinner_teardown_hook(context: ExecutionContext):
|
async def spinner_teardown_hook(context: ExecutionContext):
|
||||||
"""Removes the spinner after the action finishes (success or failure)."""
|
"""Removes the spinner after the action finishes (success or failure)."""
|
||||||
cmd = context.action
|
command = context.action
|
||||||
if cmd.options_manager is None:
|
if command.options_manager is None:
|
||||||
return
|
return
|
||||||
if hasattr(cmd, "name"):
|
if hasattr(command, "name"):
|
||||||
cmd_name = cmd.name
|
command_name = command.name
|
||||||
else:
|
else:
|
||||||
cmd_name = cmd.key
|
command_name = command.key
|
||||||
sm = context.action.options_manager.spinners
|
sm = context.action.options_manager.spinners
|
||||||
await sm.remove(cmd_name)
|
await sm.remove(command_name)
|
||||||
|
|
||||||
|
|
||||||
class ResultReporter:
|
class ResultReporter:
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Project and global initializer for Falyx CLI environments.
|
||||||
Project and global initializer for Falyx CLI environments.
|
|
||||||
|
|
||||||
This module defines functions to bootstrap a new Falyx-based CLI project or
|
This module defines functions to bootstrap a new Falyx-based CLI project or
|
||||||
create a global user-level configuration in `~/.config/falyx`.
|
create a global user-level configuration in `~/.config/falyx`.
|
||||||
|
|||||||
@@ -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."""
|
"""Global logger instance for Falyx CLI applications."""
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Defines `MenuOption` and `MenuOptionMap`, core components used to construct
|
||||||
Defines `MenuOption` and `MenuOptionMap`, core components used to construct
|
|
||||||
interactive menus within Falyx Actions such as `MenuAction` and `PromptMenuAction`.
|
interactive menus within Falyx Actions such as `MenuAction` and `PromptMenuAction`.
|
||||||
|
|
||||||
Each `MenuOption` represents a single actionable choice with a description,
|
Each `MenuOption` represents a single actionable choice with a description,
|
||||||
@@ -101,12 +100,16 @@ class MenuOptionMap(CaseInsensitiveDict):
|
|||||||
self,
|
self,
|
||||||
options: dict[str, MenuOption] | None = None,
|
options: dict[str, MenuOption] | None = None,
|
||||||
allow_reserved: bool = False,
|
allow_reserved: bool = False,
|
||||||
|
disable_reserved: bool = False,
|
||||||
):
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.allow_reserved = allow_reserved
|
self.allow_reserved = allow_reserved
|
||||||
if options:
|
if options:
|
||||||
self.update(options)
|
self.update(options)
|
||||||
|
if not disable_reserved:
|
||||||
self._inject_reserved_defaults()
|
self._inject_reserved_defaults()
|
||||||
|
else:
|
||||||
|
self.allow_reserved = True
|
||||||
|
|
||||||
def _inject_reserved_defaults(self):
|
def _inject_reserved_defaults(self):
|
||||||
from falyx.action import SignalAction
|
from falyx.action import SignalAction
|
||||||
|
|||||||
@@ -1,13 +1,42 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Runtime mode definitions for the Falyx CLI framework.
|
||||||
Defines `FalyxMode`, an enum representing the different modes of operation for Falyx.
|
|
||||||
|
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
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
class FalyxMode(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"
|
MENU = "menu"
|
||||||
RUN = "run"
|
COMMAND = "command"
|
||||||
PREVIEW = "preview"
|
PREVIEW = "preview"
|
||||||
RUN_ALL = "run-all"
|
|
||||||
HELP = "help"
|
HELP = "help"
|
||||||
|
ERROR = "error"
|
||||||
|
|||||||
68
falyx/namespace.py
Normal file
68
falyx/namespace.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
|
"""Namespace entry model for nested Falyx applications.
|
||||||
|
|
||||||
|
This module defines `FalyxNamespace`, the lightweight metadata container used to
|
||||||
|
register one `Falyx` instance inside another as a routed namespace entry.
|
||||||
|
|
||||||
|
A `FalyxNamespace` describes how a nested application should appear and behave
|
||||||
|
from the perspective of its parent namespace. It stores the public-facing key,
|
||||||
|
description, aliases, styling, and visibility flags used for routing,
|
||||||
|
completion, help rendering, and menu display, while holding a reference to the
|
||||||
|
child `Falyx` runtime that should take over once the namespace is entered.
|
||||||
|
|
||||||
|
This model is intentionally small and declarative. It does not implement
|
||||||
|
routing, rendering, or execution itself; those responsibilities remain with the
|
||||||
|
parent and child `Falyx` instances.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from rich.style import StyleType
|
||||||
|
|
||||||
|
from falyx.context import InvocationContext
|
||||||
|
from falyx.themes import OneColors
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from falyx.falyx import Falyx
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FalyxNamespace:
|
||||||
|
"""Represents a nested `Falyx` application exposed as a namespace entry.
|
||||||
|
|
||||||
|
`FalyxNamespace` is used by a parent `Falyx` instance to register and
|
||||||
|
describe a child `Falyx` runtime as a routable namespace. It provides the
|
||||||
|
metadata needed to expose that child namespace consistently across command
|
||||||
|
resolution, completion, help output, and menu rendering.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
key (str): Primary identifier used to enter the namespace.
|
||||||
|
description (str): User-facing namespace description.
|
||||||
|
namespace (Falyx): Nested `Falyx` instance activated when this namespace is
|
||||||
|
selected.
|
||||||
|
aliases (list[str]): Optional alternate names that may also resolve to the same
|
||||||
|
namespace.
|
||||||
|
help_text (str): Optional short help text used in listings or help output.
|
||||||
|
style (StyleType): Rich style used when rendering the namespace key or aliases.
|
||||||
|
hidden (bool): Whether the namespace should be omitted from visible menus and
|
||||||
|
help listings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
description: str
|
||||||
|
namespace: Falyx
|
||||||
|
aliases: list[str] = field(default_factory=list)
|
||||||
|
help_text: str = ""
|
||||||
|
style: StyleType = OneColors.CYAN
|
||||||
|
hidden: bool = False
|
||||||
|
|
||||||
|
def get_help_signature(
|
||||||
|
self, invocation_context: InvocationContext
|
||||||
|
) -> tuple[str, str, str | None]:
|
||||||
|
"""Returns the usage signature for this namespace, used in help rendering."""
|
||||||
|
usage = f"{self.key} {self.namespace._get_usage_fragment(invocation_context)}"
|
||||||
|
if self.aliases:
|
||||||
|
usage += f" (aliases: {', '.join(self.aliases)})"
|
||||||
|
return usage, self.description, self.help_text
|
||||||
@@ -1,80 +1,173 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Option state management for Falyx CLI runtimes.
|
||||||
Manages global or scoped CLI options across namespaces for Falyx commands.
|
|
||||||
|
|
||||||
The `OptionsManager` provides a centralized interface for retrieving, setting, toggling,
|
This module defines `OptionsManager`, a small utility responsible for
|
||||||
and introspecting options defined in `argparse.Namespace` objects. It is used internally
|
storing, retrieving, and temporarily overriding runtime option values across
|
||||||
by Falyx to pass and resolve runtime flags like `--verbose`, `--force-confirm`, etc.
|
named namespaces.
|
||||||
|
|
||||||
Each option is stored under a namespace key (e.g., "cli_args", "user_config") to
|
Falyx uses this manager to hold global session- and execution-scoped flags such
|
||||||
support multiple sources of configuration.
|
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:
|
In addition to basic get/set operations, the manager provides helpers for:
|
||||||
- Safe getter/setter for typed option resolution
|
|
||||||
- Toggle support for boolean options (used by bottom bar toggles, etc.)
|
|
||||||
- Callable getter/toggler wrappers for dynamic UI bindings
|
|
||||||
- Namespace merging via `from_namespace`
|
|
||||||
|
|
||||||
Typical Usage:
|
- 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 = OptionsManager()
|
||||||
options.from_namespace(args, namespace_name="cli_args")
|
options.from_mapping({"verbose": True})
|
||||||
if options.get("verbose"):
|
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:
|
with options.override_namespace({"skip_confirm": True}, "execution"):
|
||||||
- Falyx CLI runtime configuration
|
...
|
||||||
- Bottom bar toggles
|
```
|
||||||
- Dynamic flag injection into commands and actions
|
|
||||||
|
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 collections import defaultdict
|
||||||
from typing import Any, Callable
|
from contextlib import contextmanager
|
||||||
|
from typing import Any, Callable, Iterator, Mapping
|
||||||
|
|
||||||
from falyx.logger import logger
|
from falyx.logger import logger
|
||||||
from falyx.spinner_manager import SpinnerManager
|
from falyx.spinner_manager import SpinnerManager
|
||||||
|
|
||||||
|
|
||||||
class OptionsManager:
|
class OptionsManager:
|
||||||
"""
|
"""Manage mutable option values across named runtime namespaces.
|
||||||
Manages CLI option state across multiple argparse namespaces.
|
|
||||||
|
|
||||||
Allows dynamic retrieval, setting, toggling, and introspection of command-line
|
`OptionsManager` is the central store for Falyx runtime flags. Each option
|
||||||
options. Supports named namespaces (e.g., "cli_args") and is used throughout
|
is stored under a namespace name such as `"default"` or `"execution"`,
|
||||||
Falyx for runtime configuration and bottom bar toggle integration.
|
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:
|
def __init__(
|
||||||
self.options: defaultdict = defaultdict(Namespace)
|
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()
|
self.spinners = SpinnerManager()
|
||||||
if namespaces:
|
if namespaces:
|
||||||
for namespace_name, namespace in namespaces:
|
for namespace_name, namespace in namespaces:
|
||||||
self.from_namespace(namespace, namespace_name)
|
self.from_mapping(namespace, namespace_name)
|
||||||
|
|
||||||
def from_namespace(
|
def from_mapping(
|
||||||
self, namespace: Namespace, namespace_name: str = "cli_args"
|
self,
|
||||||
|
values: Mapping[str, Any],
|
||||||
|
namespace_name: str = "default",
|
||||||
) -> None:
|
) -> None:
|
||||||
self.options[namespace_name] = namespace
|
"""Merge option values into a namespace.
|
||||||
|
|
||||||
|
Existing keys in the target namespace are updated in place. Missing
|
||||||
|
namespaces are created automatically.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
values (Mapping[str, Any]): Mapping of option names to values.
|
||||||
|
namespace_name (str): Target namespace to update. Defaults to
|
||||||
|
`"default"`.
|
||||||
|
"""
|
||||||
|
self.options[namespace_name].update(dict(values))
|
||||||
|
|
||||||
def get(
|
def get(
|
||||||
self, option_name: str, default: Any = None, namespace_name: str = "cli_args"
|
self,
|
||||||
|
option_name: str,
|
||||||
|
default: Any = None,
|
||||||
|
namespace_name: str = "default",
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Get the value of an option."""
|
"""Return an option value from a namespace.
|
||||||
return getattr(self.options[namespace_name], option_name, default)
|
|
||||||
|
|
||||||
def set(self, option_name: str, value: Any, namespace_name: str = "cli_args") -> None:
|
Args:
|
||||||
"""Set the value of an option."""
|
option_name (str): Name of the option to retrieve.
|
||||||
setattr(self.options[namespace_name], option_name, value)
|
default (Any): Value to return when the option is not present.
|
||||||
|
Defaults to `None`.
|
||||||
|
namespace_name (str): Namespace to read from. Defaults to
|
||||||
|
`"default"`.
|
||||||
|
|
||||||
def has_option(self, option_name: str, namespace_name: str = "cli_args") -> bool:
|
Returns:
|
||||||
"""Check if an option exists in the namespace."""
|
Any: The stored option value if present, otherwise `default`.
|
||||||
return hasattr(self.options[namespace_name], option_name)
|
"""
|
||||||
|
return self.options[namespace_name].get(option_name, default)
|
||||||
|
|
||||||
def toggle(self, option_name: str, namespace_name: str = "cli_args") -> None:
|
def set(
|
||||||
"""Toggle a boolean option."""
|
self,
|
||||||
|
option_name: str,
|
||||||
|
value: Any,
|
||||||
|
namespace_name: str = "default",
|
||||||
|
) -> None:
|
||||||
|
"""Store an option value in a namespace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
option_name (str): Name of the option to set.
|
||||||
|
value (Any): Value to store.
|
||||||
|
namespace_name (str): Namespace to update. Defaults to `"default"`.
|
||||||
|
"""
|
||||||
|
self.options[namespace_name][option_name] = value
|
||||||
|
|
||||||
|
def has_option(
|
||||||
|
self,
|
||||||
|
option_name: str,
|
||||||
|
namespace_name: str = "default",
|
||||||
|
) -> bool:
|
||||||
|
"""Return whether an option exists in a namespace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
option_name (str): Name of the option to check.
|
||||||
|
namespace_name (str): Namespace to inspect. Defaults to `"default"`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: `True` if the option exists in the namespace, otherwise
|
||||||
|
`False`.
|
||||||
|
"""
|
||||||
|
return option_name in self.options[namespace_name]
|
||||||
|
|
||||||
|
def toggle(
|
||||||
|
self,
|
||||||
|
option_name: str,
|
||||||
|
namespace_name: str = "default",
|
||||||
|
) -> None:
|
||||||
|
"""Invert a boolean option in place.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
option_name (str): Name of the option to toggle.
|
||||||
|
namespace_name (str): Namespace containing the option. Defaults to
|
||||||
|
`"default"`.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError: If the target option is missing or is not a boolean.
|
||||||
|
"""
|
||||||
current = self.get(option_name, namespace_name=namespace_name)
|
current = self.get(option_name, namespace_name=namespace_name)
|
||||||
if not isinstance(current, bool):
|
if not isinstance(current, bool):
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
@@ -86,9 +179,24 @@ class OptionsManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_value_getter(
|
def get_value_getter(
|
||||||
self, option_name: str, namespace_name: str = "cli_args"
|
self,
|
||||||
|
option_name: str,
|
||||||
|
namespace_name: str = "default",
|
||||||
) -> Callable[[], Any]:
|
) -> Callable[[], Any]:
|
||||||
"""Get the value of an option as a getter function."""
|
"""Return a zero-argument callable that reads an option value.
|
||||||
|
|
||||||
|
This is useful for UI integrations that expect a callback instead of an
|
||||||
|
eagerly evaluated value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
option_name (str): Name of the option to read.
|
||||||
|
namespace_name (str): Namespace to read from. Defaults to
|
||||||
|
`"default"`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Callable[[], Any]: Function that returns the current option value
|
||||||
|
when called.
|
||||||
|
"""
|
||||||
|
|
||||||
def _getter() -> Any:
|
def _getter() -> Any:
|
||||||
return self.get(option_name, namespace_name=namespace_name)
|
return self.get(option_name, namespace_name=namespace_name)
|
||||||
@@ -96,17 +204,72 @@ class OptionsManager:
|
|||||||
return _getter
|
return _getter
|
||||||
|
|
||||||
def get_toggle_function(
|
def get_toggle_function(
|
||||||
self, option_name: str, namespace_name: str = "cli_args"
|
self,
|
||||||
|
option_name: str,
|
||||||
|
namespace_name: str = "default",
|
||||||
) -> Callable[[], None]:
|
) -> Callable[[], None]:
|
||||||
"""Get the toggle function for a boolean option."""
|
"""Return a zero-argument callable that toggles a boolean option.
|
||||||
|
|
||||||
|
This is useful for key bindings, bottom-bar toggles, or other UI hooks
|
||||||
|
that need a callable action.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
option_name (str): Name of the boolean option to toggle.
|
||||||
|
namespace_name (str): Namespace containing the option. Defaults to
|
||||||
|
`"default"`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Callable[[], None]: Function that toggles the option when called.
|
||||||
|
"""
|
||||||
|
|
||||||
def _toggle() -> None:
|
def _toggle() -> None:
|
||||||
self.toggle(option_name, namespace_name=namespace_name)
|
self.toggle(option_name, namespace_name=namespace_name)
|
||||||
|
|
||||||
return _toggle
|
return _toggle
|
||||||
|
|
||||||
def get_namespace_dict(self, namespace_name: str) -> Namespace:
|
def get_namespace_dict(self, namespace_name: str) -> dict[str, Any]:
|
||||||
"""Return all options in a namespace as a dictionary."""
|
"""Return a shallow copy of one namespace's option dictionary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
namespace_name (str): Namespace to snapshot.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Any]: Copy of the namespace's stored options.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the requested namespace does not exist.
|
||||||
|
"""
|
||||||
if namespace_name not in self.options:
|
if namespace_name not in self.options:
|
||||||
raise ValueError(f"Namespace '{namespace_name}' not found.")
|
raise ValueError(f"Namespace '{namespace_name}' not found.")
|
||||||
return vars(self.options[namespace_name])
|
return dict(self.options[namespace_name])
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def override_namespace(
|
||||||
|
self,
|
||||||
|
overrides: Mapping[str, Any],
|
||||||
|
namespace_name: str = "execution",
|
||||||
|
) -> Iterator[None]:
|
||||||
|
"""Temporarily apply option overrides within a namespace.
|
||||||
|
|
||||||
|
The current namespace contents are copied before the overrides are
|
||||||
|
applied. When the context exits, the original namespace state is
|
||||||
|
restored, even if an exception is raised inside the context block.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
overrides (Mapping[str, Any]): Temporary option values to merge into
|
||||||
|
the namespace.
|
||||||
|
namespace_name (str): Namespace to override. Defaults to
|
||||||
|
`"execution"`.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
None: Control is yielded to the wrapped context block.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the namespace does not already exist.
|
||||||
|
"""
|
||||||
|
original = self.get_namespace_dict(namespace_name)
|
||||||
|
try:
|
||||||
|
self.from_mapping(values=overrides, namespace_name=namespace_name)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
self.options[namespace_name] = original
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
"""
|
"""Falyx CLI Framework
|
||||||
Falyx CLI Framework
|
|
||||||
|
|
||||||
Copyright (c) 2025 rtj.dev LLC.
|
Copyright (c) 2026 rtj.dev LLC.
|
||||||
Licensed under the MIT License. See LICENSE file for details.
|
Licensed under the MIT License. See LICENSE file for details.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .argument import Argument
|
from .argument import Argument
|
||||||
from .argument_action import ArgumentAction
|
from .argument_action import ArgumentAction
|
||||||
from .command_argument_parser import CommandArgumentParser
|
from .command_argument_parser import CommandArgumentParser
|
||||||
from .parsers import FalyxParsers, get_arg_parsers, get_root_parser, get_subparsers
|
from .falyx_parser import FalyxParser
|
||||||
|
from .parse_result import ParseResult
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Argument",
|
"Argument",
|
||||||
"ArgumentAction",
|
"ArgumentAction",
|
||||||
"CommandArgumentParser",
|
"CommandArgumentParser",
|
||||||
"get_arg_parsers",
|
"FalyxParser",
|
||||||
"get_root_parser",
|
"ParseResult",
|
||||||
"get_subparsers",
|
|
||||||
"FalyxParsers",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Defines the `Argument` dataclass used by `CommandArgumentParser` to represent
|
||||||
Defines the `Argument` dataclass used by `CommandArgumentParser` to represent
|
|
||||||
individual command-line parameters in a structured, introspectable format.
|
individual command-line parameters in a structured, introspectable format.
|
||||||
|
|
||||||
Each `Argument` instance describes one CLI input, including its flags, type,
|
Each `Argument` instance describes one CLI input, including its flags, type,
|
||||||
@@ -42,8 +41,7 @@ from falyx.parser.argument_action import ArgumentAction
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Argument:
|
class Argument:
|
||||||
"""
|
"""Represents a command-line argument.
|
||||||
Represents a command-line argument.
|
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
flags (tuple[str, ...]): Short and long flags for the argument.
|
flags (tuple[str, ...]): Short and long flags for the argument.
|
||||||
@@ -60,6 +58,8 @@ class Argument:
|
|||||||
An action object that resolves the argument, if applicable.
|
An action object that resolves the argument, if applicable.
|
||||||
lazy_resolver (bool): True if the resolver should be called lazily, False otherwise
|
lazy_resolver (bool): True if the resolver should be called lazily, False otherwise
|
||||||
suggestions (list[str] | None): Optional completions for interactive shells
|
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, ...]
|
flags: tuple[str, ...]
|
||||||
@@ -75,6 +75,8 @@ class Argument:
|
|||||||
resolver: BaseAction | None = None
|
resolver: BaseAction | None = None
|
||||||
lazy_resolver: bool = False
|
lazy_resolver: bool = False
|
||||||
suggestions: list[str] | None = None
|
suggestions: list[str] | None = None
|
||||||
|
group: str | None = None
|
||||||
|
mutex_group: str | None = None
|
||||||
|
|
||||||
def get_positional_text(self) -> str:
|
def get_positional_text(self) -> str:
|
||||||
"""Get the positional text for the argument."""
|
"""Get the positional text for the argument."""
|
||||||
@@ -132,6 +134,8 @@ class Argument:
|
|||||||
and self.positional == other.positional
|
and self.positional == other.positional
|
||||||
and self.default == other.default
|
and self.default == other.default
|
||||||
and self.help == other.help
|
and self.help == other.help
|
||||||
|
and self.group == other.group
|
||||||
|
and self.mutex_group == other.mutex_group
|
||||||
)
|
)
|
||||||
|
|
||||||
def __hash__(self) -> int:
|
def __hash__(self) -> int:
|
||||||
@@ -147,5 +151,7 @@ class Argument:
|
|||||||
self.positional,
|
self.positional,
|
||||||
self.default,
|
self.default,
|
||||||
self.help,
|
self.help,
|
||||||
|
self.group,
|
||||||
|
self.mutex_group,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Defines `ArgumentAction`, an enum used to standardize the behavior of CLI arguments
|
||||||
Defines `ArgumentAction`, an enum used to standardize the behavior of CLI arguments
|
|
||||||
defined within Falyx command configurations.
|
defined within Falyx command configurations.
|
||||||
|
|
||||||
Each member of this enum maps to a valid `argparse` like actions or Falyx-specific
|
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):
|
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
|
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
|
Falyx-specific extensions. It is used when defining command-line arguments for
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
650
falyx/parser/falyx_parser.py
Normal file
650
falyx/parser/falyx_parser.py
Normal file
@@ -0,0 +1,650 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from falyx.console import console
|
||||||
|
from falyx.exceptions import EntryNotFoundError, FalyxOptionError
|
||||||
|
from falyx.mode import FalyxMode
|
||||||
|
from falyx.options_manager import OptionsManager
|
||||||
|
from falyx.parser.parse_result import ParseResult
|
||||||
|
from falyx.parser.parser_types import (
|
||||||
|
FalyxTLDRExample,
|
||||||
|
FalyxTLDRInput,
|
||||||
|
false_none,
|
||||||
|
true_none,
|
||||||
|
)
|
||||||
|
from falyx.parser.utils import coerce_value, get_type_name
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from falyx.falyx import Falyx
|
||||||
|
|
||||||
|
builtin_type = type
|
||||||
|
|
||||||
|
|
||||||
|
class OptionAction(Enum):
|
||||||
|
STORE = "store"
|
||||||
|
STORE_TRUE = "store_true"
|
||||||
|
STORE_FALSE = "store_false"
|
||||||
|
STORE_BOOL_OPTIONAL = "store_bool_optional"
|
||||||
|
COUNT = "count"
|
||||||
|
HELP = "help"
|
||||||
|
TLDR = "tldr"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def choices(cls) -> list[OptionAction]:
|
||||||
|
"""Return a list of all argument actions."""
|
||||||
|
return list(cls)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_alias(cls, value: str) -> str:
|
||||||
|
aliases = {
|
||||||
|
"optional": "store_bool_optional",
|
||||||
|
"true": "store_true",
|
||||||
|
"false": "store_false",
|
||||||
|
}
|
||||||
|
return aliases.get(value, value)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _missing_(cls, value: object) -> OptionAction:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(f"Invalid {cls.__name__}: {value!r}")
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
alias = cls._get_alias(normalized)
|
||||||
|
for member in cls:
|
||||||
|
if member.value == alias:
|
||||||
|
return member
|
||||||
|
valid = ", ".join(member.value for member in cls)
|
||||||
|
raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}")
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Return the string representation of the argument action."""
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
|
||||||
|
class OptionScope(Enum):
|
||||||
|
ROOT = "root"
|
||||||
|
NAMESPACE = "namespace"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _missing_(cls, value: object) -> OptionScope:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(f"Invalid {cls.__name__}: {value!r}")
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
for member in cls:
|
||||||
|
if member.value == normalized:
|
||||||
|
return member
|
||||||
|
valid = ", ".join(member.value for member in cls)
|
||||||
|
raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Option:
|
||||||
|
flags: tuple[str, ...]
|
||||||
|
dest: str
|
||||||
|
action: OptionAction = OptionAction.STORE
|
||||||
|
type: Any = str
|
||||||
|
default: Any = None
|
||||||
|
choices: list[str] | None = None
|
||||||
|
help: str = ""
|
||||||
|
suggestions: list[str] | None = None
|
||||||
|
scope: OptionScope = OptionScope.NAMESPACE
|
||||||
|
|
||||||
|
def format_for_help(self) -> str:
|
||||||
|
"""Return a formatted string of the option's flags for help output."""
|
||||||
|
return ", ".join(self.flags)
|
||||||
|
|
||||||
|
|
||||||
|
class FalyxParser:
|
||||||
|
RESERVED_DESTS: set[str] = {"help", "tldr"}
|
||||||
|
|
||||||
|
def __init__(self, flx: Falyx) -> None:
|
||||||
|
self._flx = flx
|
||||||
|
self._options_by_dest: dict[str, Option] = {}
|
||||||
|
self._options: list[Option] = []
|
||||||
|
self._dest_set: set[str] = set()
|
||||||
|
self._tldr_examples: list[FalyxTLDRExample] = []
|
||||||
|
self._add_reserved_options()
|
||||||
|
self.help_option: Option | None = None
|
||||||
|
self.tldr_option: Option | None = None
|
||||||
|
|
||||||
|
def get_flags(self) -> list[str]:
|
||||||
|
"""Return a list of the first flag for the registered options."""
|
||||||
|
return [option.flags[0] for option in self._options]
|
||||||
|
|
||||||
|
def get_options(self) -> list[Option]:
|
||||||
|
"""Return a list of registered options."""
|
||||||
|
return self._options
|
||||||
|
|
||||||
|
def _add_tldr(self):
|
||||||
|
"""Add TLDR argument to the parser."""
|
||||||
|
if "tldr" in self._dest_set:
|
||||||
|
return None
|
||||||
|
tldr = Option(
|
||||||
|
flags=("--tldr", "-T"),
|
||||||
|
action=OptionAction.TLDR,
|
||||||
|
help="Show quick usage examples.",
|
||||||
|
dest="tldr",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
self._register_option(tldr)
|
||||||
|
self.tldr_option = tldr
|
||||||
|
|
||||||
|
def add_tldr_example(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
entry_key: str,
|
||||||
|
usage: str,
|
||||||
|
description: str,
|
||||||
|
) -> None:
|
||||||
|
"""Register a single namespace-level TLDR example.
|
||||||
|
|
||||||
|
The referenced entry must resolve to a known command or namespace in the
|
||||||
|
current `Falyx` instance. Unknown entries are reported to the console and
|
||||||
|
are not added.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entry_key (str): Command or namespace key the example is associated with.
|
||||||
|
usage (str): Example usage fragment shown after the resolved invocation path.
|
||||||
|
description (str): Short explanation displayed alongside the example.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
EntryNotFoundError: If `entry_key` cannot be resolved to a known command or
|
||||||
|
namespace in this `Falyx` instance.
|
||||||
|
"""
|
||||||
|
entry, suggestions = self._flx.resolve_entry(entry_key)
|
||||||
|
if not entry:
|
||||||
|
raise EntryNotFoundError(
|
||||||
|
unknown_name=entry_key,
|
||||||
|
suggestions=suggestions,
|
||||||
|
message_context="TLDR example",
|
||||||
|
)
|
||||||
|
self._tldr_examples.append(
|
||||||
|
FalyxTLDRExample(entry_key=entry_key, usage=usage, description=description)
|
||||||
|
)
|
||||||
|
self._add_tldr()
|
||||||
|
|
||||||
|
def add_tldr_examples(self, examples: list[FalyxTLDRInput]) -> None:
|
||||||
|
"""Register multiple namespace-level TLDR examples.
|
||||||
|
|
||||||
|
Supports either `FalyxTLDRExample` objects or shorthand tuples of
|
||||||
|
`(entry_key, usage, description)`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
examples (list[FalyxTLDRInput]): Example definitions to validate and append.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FalyxError: If an example has an unsupported shape.
|
||||||
|
EntryNotFoundError: If `entry_key` cannot be resolved to a known command or
|
||||||
|
namespace in this `Falyx` instance.
|
||||||
|
"""
|
||||||
|
for example in examples:
|
||||||
|
if isinstance(example, FalyxTLDRExample):
|
||||||
|
entry, suggestions = self._flx.resolve_entry(example.entry_key)
|
||||||
|
if not entry:
|
||||||
|
raise EntryNotFoundError(
|
||||||
|
unknown_name=example.entry_key,
|
||||||
|
suggestions=suggestions,
|
||||||
|
message_context="TLDR example",
|
||||||
|
)
|
||||||
|
self._tldr_examples.append(example)
|
||||||
|
self._add_tldr()
|
||||||
|
elif len(example) == 3:
|
||||||
|
entry_key, usage, description = example
|
||||||
|
self.add_tldr_example(
|
||||||
|
entry_key=entry_key,
|
||||||
|
usage=usage,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
self._add_tldr()
|
||||||
|
else:
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"invalid TLDR example format: {example}.\n"
|
||||||
|
"examples must be either FalyxTLDRExample instances "
|
||||||
|
"or tuples of (entry_key, usage, description).",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _add_reserved_options(self) -> None:
|
||||||
|
help = Option(
|
||||||
|
flags=("-h", "--help", "?"),
|
||||||
|
dest="help",
|
||||||
|
action=OptionAction.HELP,
|
||||||
|
help="Show root-level help output and exit.",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
self._register_option(help)
|
||||||
|
self.help_option = help
|
||||||
|
|
||||||
|
if not self._flx.disable_verbose_option:
|
||||||
|
verbose = Option(
|
||||||
|
flags=("-v", "--verbose"),
|
||||||
|
dest="verbose",
|
||||||
|
action=OptionAction.STORE_TRUE,
|
||||||
|
help="Enable verbose logging for the session.",
|
||||||
|
default=False,
|
||||||
|
scope=OptionScope.ROOT,
|
||||||
|
)
|
||||||
|
self._register_option(verbose)
|
||||||
|
|
||||||
|
if not self._flx.disable_debug_hooks_option:
|
||||||
|
debug_hooks = Option(
|
||||||
|
flags=("-d", "--debug-hooks"),
|
||||||
|
dest="debug_hooks",
|
||||||
|
action=OptionAction.STORE_TRUE,
|
||||||
|
help="Log hook execution in detail for the session.",
|
||||||
|
default=False,
|
||||||
|
scope=OptionScope.ROOT,
|
||||||
|
)
|
||||||
|
self._register_option(debug_hooks)
|
||||||
|
|
||||||
|
if not self._flx.disable_never_prompt_option:
|
||||||
|
never_prompt = Option(
|
||||||
|
flags=("-n", "--never-prompt"),
|
||||||
|
dest="never_prompt",
|
||||||
|
action=OptionAction.STORE_TRUE,
|
||||||
|
help="Suppress all prompts for the session.",
|
||||||
|
default=False,
|
||||||
|
scope=OptionScope.ROOT,
|
||||||
|
)
|
||||||
|
self._register_option(never_prompt)
|
||||||
|
|
||||||
|
def _register_store_bool_optional(
|
||||||
|
self,
|
||||||
|
flags: tuple[str, ...],
|
||||||
|
dest: str,
|
||||||
|
help: str,
|
||||||
|
) -> None:
|
||||||
|
"""Register a store_bool_optional action with the parser."""
|
||||||
|
if len(flags) != 1:
|
||||||
|
raise FalyxOptionError(
|
||||||
|
"store_bool_optional action can only have a single flag"
|
||||||
|
)
|
||||||
|
if not flags[0].startswith("--"):
|
||||||
|
raise FalyxOptionError(
|
||||||
|
"store_bool_optional action must use a long flag (e.g. --flag)"
|
||||||
|
)
|
||||||
|
base_flag = flags[0]
|
||||||
|
negated_flag = f"--no-{base_flag.lstrip('-')}"
|
||||||
|
|
||||||
|
argument = Option(
|
||||||
|
flags=flags,
|
||||||
|
dest=dest,
|
||||||
|
action=OptionAction.STORE_BOOL_OPTIONAL,
|
||||||
|
type=true_none,
|
||||||
|
default=None,
|
||||||
|
help=help,
|
||||||
|
)
|
||||||
|
|
||||||
|
negated_argument = Option(
|
||||||
|
flags=(negated_flag,),
|
||||||
|
dest=dest,
|
||||||
|
action=OptionAction.STORE_BOOL_OPTIONAL,
|
||||||
|
type=false_none,
|
||||||
|
default=None,
|
||||||
|
help=help,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._register_option(argument)
|
||||||
|
self._register_option(negated_argument, bypass_validation=True)
|
||||||
|
|
||||||
|
def _register_option(self, option: Option, bypass_validation: bool = False) -> None:
|
||||||
|
self._dest_set.add(option.dest)
|
||||||
|
self._options.append(option)
|
||||||
|
for flag in option.flags:
|
||||||
|
if flag in self._options and not bypass_validation:
|
||||||
|
existing = self._options_by_dest[flag]
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"flag '{flag}' is already used by argument '{existing.dest}'"
|
||||||
|
)
|
||||||
|
self._options_by_dest[flag] = option
|
||||||
|
|
||||||
|
def _validate_flags(self, flags: tuple[str, ...]) -> None:
|
||||||
|
if not flags:
|
||||||
|
raise FalyxOptionError("no flags provided for option")
|
||||||
|
for flag in flags:
|
||||||
|
if not isinstance(flag, str):
|
||||||
|
raise FalyxOptionError(f"invalid flag '{flag}': must be a string")
|
||||||
|
if not flag.startswith("-"):
|
||||||
|
raise FalyxOptionError(f"invalid flag '{flag}': must start with '-'")
|
||||||
|
if flag.startswith("--") and len(flag) < 3:
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"invalid flag '{flag}': long flags must have at least one character after '--'"
|
||||||
|
)
|
||||||
|
if flag.startswith("-") and not flag.startswith("--") and len(flag) > 2:
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"invalid flag '{flag}': short flags must be a single character"
|
||||||
|
)
|
||||||
|
if flag in self._options_by_dest:
|
||||||
|
existing = self._options_by_dest[flag]
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"flag '{flag}' is already used by argument '{existing.dest}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_dest_from_flags(self, flags: tuple[str, ...], dest: str | None) -> str:
|
||||||
|
if dest:
|
||||||
|
if not dest.replace("_", "").isalnum():
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"invalid dest '{dest}': must be a valid identifier (letters, digits, and underscores only)"
|
||||||
|
)
|
||||||
|
if dest[0].isdigit():
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"invalid dest '{dest}': cannot start with a digit"
|
||||||
|
)
|
||||||
|
return dest
|
||||||
|
dest = None
|
||||||
|
for flag in flags:
|
||||||
|
cleaned = flag.lstrip("-").replace("-", "_").lower()
|
||||||
|
dest = cleaned
|
||||||
|
if flag.startswith("--"):
|
||||||
|
break
|
||||||
|
assert dest is not None, "dest should not be None"
|
||||||
|
if not dest.replace("_", "").isalnum():
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"invalid dest '{dest}': must be a valid identifier (letters, digits, and underscores only)"
|
||||||
|
)
|
||||||
|
if dest[0].isdigit():
|
||||||
|
raise FalyxOptionError(f"invalid dest '{dest}': cannot start with a digit")
|
||||||
|
return dest
|
||||||
|
|
||||||
|
def _validate_action(self, action: str | OptionAction) -> OptionAction:
|
||||||
|
if isinstance(action, OptionAction):
|
||||||
|
return action
|
||||||
|
try:
|
||||||
|
return OptionAction(action)
|
||||||
|
except ValueError as error:
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"invalid option action '{action}' is not a valid OptionAction",
|
||||||
|
hint=f"valid actions are: {', '.join(a.value for a in OptionAction)}",
|
||||||
|
) from error
|
||||||
|
|
||||||
|
def _resolve_default(
|
||||||
|
self,
|
||||||
|
default: Any,
|
||||||
|
action: OptionAction,
|
||||||
|
) -> Any:
|
||||||
|
if default is None:
|
||||||
|
if action == OptionAction.STORE_TRUE:
|
||||||
|
return False
|
||||||
|
elif action == OptionAction.STORE_FALSE:
|
||||||
|
return True
|
||||||
|
elif action == OptionAction.STORE_BOOL_OPTIONAL:
|
||||||
|
return None
|
||||||
|
elif action == OptionAction.COUNT:
|
||||||
|
return 0
|
||||||
|
elif action is OptionAction.STORE_TRUE and default is not False:
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"default value for '{action}' action must be False or None, got {default!r}"
|
||||||
|
)
|
||||||
|
elif action is OptionAction.STORE_FALSE and default is not True:
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"default value for '{action}' action must be True or None, got {default!r}"
|
||||||
|
)
|
||||||
|
elif action is OptionAction.STORE_BOOL_OPTIONAL:
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"default value for '{action}' action must be None, got {default!r}"
|
||||||
|
)
|
||||||
|
elif action in (OptionAction.HELP, OptionAction.TLDR, OptionAction.COUNT):
|
||||||
|
raise FalyxOptionError(f"default value cannot be set for action '{action}'.")
|
||||||
|
return default
|
||||||
|
|
||||||
|
def _validate_default_type(
|
||||||
|
self,
|
||||||
|
default: Any,
|
||||||
|
expected_type: Any,
|
||||||
|
dest: str,
|
||||||
|
) -> None:
|
||||||
|
if default is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
coerce_value(default, expected_type)
|
||||||
|
except Exception as error:
|
||||||
|
type_name = get_type_name(expected_type)
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"invalid default value {default!r} for '{dest}' cannot be coerced to {type_name} error: {error}"
|
||||||
|
) from error
|
||||||
|
|
||||||
|
def _normalize_choices(
|
||||||
|
self,
|
||||||
|
choices: list[str] | None,
|
||||||
|
expected_type: type,
|
||||||
|
action: OptionAction,
|
||||||
|
) -> list[Any]:
|
||||||
|
if choices is None:
|
||||||
|
choices = []
|
||||||
|
else:
|
||||||
|
if action in (
|
||||||
|
OptionAction.STORE_TRUE,
|
||||||
|
OptionAction.STORE_FALSE,
|
||||||
|
OptionAction.STORE_BOOL_OPTIONAL,
|
||||||
|
):
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"choices cannot be specified for '{action}' actions"
|
||||||
|
)
|
||||||
|
if isinstance(choices, dict):
|
||||||
|
raise FalyxOptionError("choices cannot be a dict")
|
||||||
|
try:
|
||||||
|
choices = list(choices)
|
||||||
|
except TypeError as error:
|
||||||
|
raise FalyxOptionError(
|
||||||
|
"choices must be iterable (like list, tuple, or set)"
|
||||||
|
) from error
|
||||||
|
for choice in choices:
|
||||||
|
try:
|
||||||
|
coerce_value(choice, expected_type)
|
||||||
|
except Exception as error:
|
||||||
|
type_name = get_type_name(expected_type)
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"invalid choice {choice!r} cannot be coerced to {type_name} error: {error}"
|
||||||
|
) from error
|
||||||
|
return choices
|
||||||
|
|
||||||
|
def add_option(
|
||||||
|
self,
|
||||||
|
flags: tuple[str, ...],
|
||||||
|
dest: str,
|
||||||
|
action: str | OptionAction = "store",
|
||||||
|
type: type = str,
|
||||||
|
default: Any = None,
|
||||||
|
choices: list[str] | None = None,
|
||||||
|
help: str = "",
|
||||||
|
suggestions: list[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._validate_flags(flags)
|
||||||
|
dest = self._get_dest_from_flags(flags, dest)
|
||||||
|
if dest in self.RESERVED_DESTS:
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"invalid dest '{dest}': '{dest}' is reserved and cannot be used as an option dest"
|
||||||
|
)
|
||||||
|
if dest in self._dest_set:
|
||||||
|
raise FalyxOptionError(f"duplicate option dest '{dest}'")
|
||||||
|
action = self._validate_action(action)
|
||||||
|
default = self._resolve_default(default, action)
|
||||||
|
self._validate_default_type(default, type, dest)
|
||||||
|
choices = self._normalize_choices(choices, type, action)
|
||||||
|
if default is not None and choices and default not in choices:
|
||||||
|
choices_str = ", ".join((str(choice) for choice in choices))
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"default value {default!r} is not in allowed choices: {choices_str}"
|
||||||
|
)
|
||||||
|
if suggestions is not None and not isinstance(suggestions, list):
|
||||||
|
type_name = get_type_name(suggestions)
|
||||||
|
raise FalyxOptionError(f"suggestions must be a list or None, got {type_name}")
|
||||||
|
if isinstance(suggestions, list) and not all(
|
||||||
|
isinstance(suggestion, str) for suggestion in suggestions
|
||||||
|
):
|
||||||
|
raise FalyxOptionError("suggestions must be a list of strings")
|
||||||
|
if action is OptionAction.STORE_BOOL_OPTIONAL:
|
||||||
|
self._register_store_bool_optional(flags, dest, help)
|
||||||
|
return None
|
||||||
|
option = Option(
|
||||||
|
flags=flags,
|
||||||
|
dest=dest,
|
||||||
|
action=action,
|
||||||
|
type=type,
|
||||||
|
default=default,
|
||||||
|
choices=choices,
|
||||||
|
help=help,
|
||||||
|
suggestions=suggestions,
|
||||||
|
)
|
||||||
|
self._register_option(option)
|
||||||
|
|
||||||
|
def apply_to_options(
|
||||||
|
self,
|
||||||
|
parse_result: ParseResult,
|
||||||
|
options: OptionsManager,
|
||||||
|
) -> None:
|
||||||
|
for dest, value in parse_result.options.items():
|
||||||
|
options.set(dest, value, namespace_name=self_flx.namespace_name)
|
||||||
|
for dest, value in parse_result.root_options.items():
|
||||||
|
options.set(dest, value, namespace_name="root")
|
||||||
|
|
||||||
|
def _can_bundle_option(self, option: Option) -> bool:
|
||||||
|
return option.action in {
|
||||||
|
OptionAction.STORE_TRUE,
|
||||||
|
OptionAction.STORE_FALSE,
|
||||||
|
OptionAction.COUNT,
|
||||||
|
OptionAction.HELP,
|
||||||
|
OptionAction.TLDR,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _resolve_posix_bundling(self, tokens: list[str]) -> list[str]:
|
||||||
|
"""Expand POSIX-style bundled arguments into separate arguments."""
|
||||||
|
expanded: list[str] = []
|
||||||
|
for token in tokens:
|
||||||
|
if not token.startswith("-") or token.startswith("--") or len(token) <= 2:
|
||||||
|
expanded.append(token)
|
||||||
|
continue
|
||||||
|
|
||||||
|
bundle = [f"-{char}" for char in token[1:]]
|
||||||
|
|
||||||
|
if (
|
||||||
|
all(
|
||||||
|
flag in self._options_by_dest
|
||||||
|
and self._can_bundle_option(self._options_by_dest[flag])
|
||||||
|
for flag in bundle[:-1]
|
||||||
|
)
|
||||||
|
and bundle[-1] in self._options_by_dest
|
||||||
|
):
|
||||||
|
expanded.extend(bundle)
|
||||||
|
else:
|
||||||
|
expanded.append(token)
|
||||||
|
return expanded
|
||||||
|
|
||||||
|
def _default_values(self) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
|
values: dict[str, Any] = {}
|
||||||
|
root_values: dict[str, Any] = {}
|
||||||
|
|
||||||
|
for option in self._options:
|
||||||
|
if option.scope == OptionScope.ROOT:
|
||||||
|
root_values[option.dest] = option.default
|
||||||
|
elif option.scope == OptionScope.NAMESPACE:
|
||||||
|
values.setdefault(option.dest, option.default)
|
||||||
|
else:
|
||||||
|
assert False, f"unhandled option scope: {option.scope}"
|
||||||
|
|
||||||
|
return values, root_values
|
||||||
|
|
||||||
|
def _consume_option(
|
||||||
|
self,
|
||||||
|
option: Option,
|
||||||
|
argv: list[str],
|
||||||
|
index: int,
|
||||||
|
values: dict[str, Any],
|
||||||
|
) -> int:
|
||||||
|
match option.action:
|
||||||
|
case OptionAction.STORE_TRUE:
|
||||||
|
values[option.dest] = True
|
||||||
|
return index + 1
|
||||||
|
|
||||||
|
case OptionAction.STORE_FALSE:
|
||||||
|
values[option.dest] = False
|
||||||
|
return index + 1
|
||||||
|
|
||||||
|
case OptionAction.STORE_BOOL_OPTIONAL:
|
||||||
|
values[option.dest] = option.type(None)
|
||||||
|
return index + 1
|
||||||
|
|
||||||
|
case OptionAction.COUNT:
|
||||||
|
values[option.dest] = int(values.get(option.dest) or 0) + 1
|
||||||
|
return index + 1
|
||||||
|
|
||||||
|
case OptionAction.HELP:
|
||||||
|
values[option.dest] = True
|
||||||
|
return index + 1
|
||||||
|
|
||||||
|
case OptionAction.TLDR:
|
||||||
|
values[option.dest] = True
|
||||||
|
return index + 1
|
||||||
|
|
||||||
|
case OptionAction.STORE:
|
||||||
|
value_index = index + 1
|
||||||
|
if value_index >= len(argv):
|
||||||
|
raise FalyxOptionError(f"option '{argv[index]}' expected a value")
|
||||||
|
|
||||||
|
raw_value = argv[value_index]
|
||||||
|
try:
|
||||||
|
value = coerce_value(raw_value, option.type)
|
||||||
|
except Exception as error:
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"invalid value for '{argv[index]}': {error}"
|
||||||
|
) from error
|
||||||
|
|
||||||
|
if option.choices and value not in option.choices:
|
||||||
|
choices = ", ".join(str(choice) for choice in option.choices)
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"invalid value for '{argv[index]}': expected one of {{{choices}}}"
|
||||||
|
)
|
||||||
|
|
||||||
|
values[option.dest] = value
|
||||||
|
return index + 2
|
||||||
|
|
||||||
|
raise FalyxOptionError(f"unsupported option action: {option.action}")
|
||||||
|
|
||||||
|
def parse_args(
|
||||||
|
self,
|
||||||
|
argv: list[str] | None = None,
|
||||||
|
) -> ParseResult:
|
||||||
|
raw_argv = argv or []
|
||||||
|
arguments = self._resolve_posix_bundling(raw_argv)
|
||||||
|
values, root_values = self._default_values()
|
||||||
|
|
||||||
|
index = 0
|
||||||
|
while index < len(arguments):
|
||||||
|
token = arguments[index]
|
||||||
|
|
||||||
|
# Explicit option terminator. Everything after belongs to routing/command.
|
||||||
|
if token == "--":
|
||||||
|
index += 1
|
||||||
|
break
|
||||||
|
|
||||||
|
# First non-option is the route boundary.
|
||||||
|
if not token.startswith("-"):
|
||||||
|
break
|
||||||
|
|
||||||
|
# Unknown leading option is an error at this scope.
|
||||||
|
# This is what keeps root/namespace options honest.
|
||||||
|
option = self._options_by_dest.get(token)
|
||||||
|
if option is None:
|
||||||
|
raise FalyxOptionError(
|
||||||
|
f"unknown option '{token}' for '{self._flx.program or self._flx.title}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
target_values = root_values if option.scope == OptionScope.ROOT else values
|
||||||
|
index = self._consume_option(option, arguments, index, target_values)
|
||||||
|
|
||||||
|
remaining_argv = arguments[index:]
|
||||||
|
|
||||||
|
help_requested = values.get("help", False) or values.get("tldr", False)
|
||||||
|
|
||||||
|
return ParseResult(
|
||||||
|
mode=FalyxMode.HELP if help_requested else FalyxMode.COMMAND,
|
||||||
|
raw_argv=raw_argv,
|
||||||
|
options=values,
|
||||||
|
root_options=root_values,
|
||||||
|
remaining_argv=remaining_argv,
|
||||||
|
help=values.get("help", False),
|
||||||
|
tldr=values.get("tldr", False),
|
||||||
|
current_head=remaining_argv[0] if remaining_argv else "",
|
||||||
|
)
|
||||||
76
falyx/parser/group.py
Normal file
76
falyx/parser/group.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
|
"""Argument grouping models for the Falyx command argument parser.
|
||||||
|
|
||||||
|
This module defines lightweight dataclasses used by
|
||||||
|
`CommandArgumentParser` to organize arguments into named help sections and
|
||||||
|
mutually exclusive sets.
|
||||||
|
|
||||||
|
It provides:
|
||||||
|
|
||||||
|
- `ArgumentGroup`, which represents a logical collection of related argument
|
||||||
|
destinations for grouped help rendering.
|
||||||
|
- `MutuallyExclusiveGroup`, which represents a set of argument destinations
|
||||||
|
where only one member may be selected, with optional group-level
|
||||||
|
requiredness.
|
||||||
|
|
||||||
|
These models are metadata containers only. They do not perform parsing or
|
||||||
|
validation themselves. Instead, they are populated and enforced by
|
||||||
|
`CommandArgumentParser` during argument registration, parsing, and help
|
||||||
|
generation.
|
||||||
|
|
||||||
|
This module exists to keep argument-group state explicit, structured, and easy
|
||||||
|
to introspect.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ArgumentGroup:
|
||||||
|
"""Represents a named group of related command argument destinations.
|
||||||
|
|
||||||
|
`ArgumentGroup` is used by `CommandArgumentParser` to collect arguments that
|
||||||
|
belong together conceptually so they can be rendered under a shared section
|
||||||
|
in help output and tracked as a unit in parser metadata.
|
||||||
|
|
||||||
|
This class stores only grouping metadata and does not implement any parsing
|
||||||
|
behavior on its own.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: User-facing name of the argument group.
|
||||||
|
description: Optional descriptive text for the group, typically used in
|
||||||
|
help rendering.
|
||||||
|
dests: Destination names of arguments assigned to this group.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
dests: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class MutuallyExclusiveGroup:
|
||||||
|
"""Represents a mutually exclusive set of argument destinations.
|
||||||
|
|
||||||
|
`MutuallyExclusiveGroup` is used by `CommandArgumentParser` to model groups
|
||||||
|
of arguments where only one member may be provided at a time. It can also
|
||||||
|
mark the group as required, meaning that exactly one of the grouped
|
||||||
|
arguments must be present.
|
||||||
|
|
||||||
|
This class stores group metadata only. Validation and enforcement are
|
||||||
|
performed by the parser.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: User-facing name of the mutually exclusive group.
|
||||||
|
required: Whether at least one argument in the group must be supplied.
|
||||||
|
description: Optional descriptive text for the group, typically used in
|
||||||
|
help rendering.
|
||||||
|
dests: Destination names of arguments assigned to this mutually
|
||||||
|
exclusive group.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
required: bool = False
|
||||||
|
description: str = ""
|
||||||
|
dests: list[str] = field(default_factory=list)
|
||||||
64
falyx/parser/parse_result.py
Normal file
64
falyx/parser/parse_result.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
|
"""Parse result model for the Falyx CLI runtime.
|
||||||
|
|
||||||
|
This module defines `ParseResult`, the normalized output produced by the
|
||||||
|
root-level Falyx parsing stage.
|
||||||
|
|
||||||
|
`ParseResult` captures the session-scoped state derived from the initial
|
||||||
|
CLI parse before namespace routing or command-local argument parsing begins. It
|
||||||
|
records the selected top-level mode, the original argv, root option flags, and
|
||||||
|
any remaining argv that should be forwarded into the routed execution layer.
|
||||||
|
|
||||||
|
This model is typically produced by `FalyxParser.parse()` and then consumed by
|
||||||
|
higher-level Falyx runtime entrypoints such as `Falyx.run()` to configure
|
||||||
|
logging, prompt behavior, help rendering, and routed command dispatch.
|
||||||
|
|
||||||
|
The dataclass is intentionally lightweight and focused on root parsing only. It
|
||||||
|
does not perform parsing, validation, or execution itself.
|
||||||
|
"""
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from falyx.mode import FalyxMode
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ParseResult:
|
||||||
|
"""Represents the normalized result of root-level Falyx argument parsing.
|
||||||
|
|
||||||
|
`ParseResult` stores the outcome of the initial CLI parse that occurs at
|
||||||
|
the application boundary. It separates session-level runtime settings from
|
||||||
|
the remaining argv that should continue into namespace routing and
|
||||||
|
command-local parsing.
|
||||||
|
|
||||||
|
This model is used to communicate root parsing decisions cleanly to the
|
||||||
|
rest of the Falyx runtime, including whether the application should enter
|
||||||
|
help mode or continue with normal command execution.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
mode: Top-level runtime mode selected from the root parse.
|
||||||
|
raw_argv: Original argv passed into the root parser.
|
||||||
|
options: Dictionary of parsed root-level options and their values.
|
||||||
|
root_options: Dictionary of parsed root-level options that should be
|
||||||
|
applied at the root level for all namespaces.
|
||||||
|
remaining_argv: Unconsumed argv that should be forwarded to routed
|
||||||
|
command resolution.
|
||||||
|
current_head: The current head token being processed (for error reporting).
|
||||||
|
help: Whether help output was requested at the root level.
|
||||||
|
tldr: Whether TLDR output was requested at the root level.
|
||||||
|
verbose: Whether verbose logging should be enabled for the session.
|
||||||
|
debug_hooks: Whether hook execution should be logged in detail.
|
||||||
|
never_prompt: Whether prompts should be suppressed for the session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
mode: FalyxMode
|
||||||
|
raw_argv: list[str] = field(default_factory=list)
|
||||||
|
options: dict[str, Any] = field(default_factory=dict)
|
||||||
|
root_options: dict[str, Any] = field(default_factory=dict)
|
||||||
|
remaining_argv: list[str] = field(default_factory=list)
|
||||||
|
current_head: str = ""
|
||||||
|
help: bool = False
|
||||||
|
tldr: bool = False
|
||||||
|
verbose: bool = False
|
||||||
|
debug_hooks: bool = False
|
||||||
|
never_prompt: bool = False
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Type utilities and argument state models for Falyx's custom CLI argument parser.
|
||||||
Type utilities and argument state models for Falyx's custom CLI argument parser.
|
|
||||||
|
|
||||||
This module provides specialized helpers and data structures used by
|
This module provides specialized helpers and data structures used by
|
||||||
the `CommandArgumentParser` to handle non-standard parsing behavior.
|
the `CommandArgumentParser` to handle non-standard parsing behavior.
|
||||||
@@ -17,7 +16,7 @@ These tools support richer expressiveness and user-friendly ergonomics in
|
|||||||
Falyx's declarative command-line interfaces.
|
Falyx's declarative command-line interfaces.
|
||||||
"""
|
"""
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any, TypeAlias
|
||||||
|
|
||||||
from falyx.parser.argument import Argument
|
from falyx.parser.argument import Argument
|
||||||
|
|
||||||
@@ -50,6 +49,21 @@ class TLDRExample:
|
|||||||
description: str
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
TLDRInput: TypeAlias = TLDRExample | tuple[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FalyxTLDRExample:
|
||||||
|
"""Represents a usage example for Falyx TLDR output, with optional metadata."""
|
||||||
|
|
||||||
|
entry_key: str
|
||||||
|
usage: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
FalyxTLDRInput: TypeAlias = FalyxTLDRExample | tuple[str, str, str]
|
||||||
|
|
||||||
|
|
||||||
def true_none(value: Any) -> bool | None:
|
def true_none(value: Any) -> bool | None:
|
||||||
"""Return True if value is not None, else None."""
|
"""Return True if value is not None, else None."""
|
||||||
if value is None:
|
if value is None:
|
||||||
|
|||||||
@@ -1,408 +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`, `help`, 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
|
|
||||||
help: 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 help' 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 preview [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`, `help`, 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`, `help`, `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} help' 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} preview [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")
|
|
||||||
|
|
||||||
help_parser = subparsers.add_parser("help", help="List all available commands")
|
|
||||||
|
|
||||||
help_parser.add_argument(
|
|
||||||
"-k",
|
|
||||||
"--key",
|
|
||||||
help="Show help for a specific command by its key or alias",
|
|
||||||
default=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
help_parser.add_argument(
|
|
||||||
"-T",
|
|
||||||
"--tldr",
|
|
||||||
action="store_true",
|
|
||||||
help="Show a simplified TLDR examples of a command if available",
|
|
||||||
)
|
|
||||||
|
|
||||||
help_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,
|
|
||||||
help=help_parser,
|
|
||||||
version=version_parser,
|
|
||||||
)
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# 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`.
|
metadata compatible with Falyx's `CommandArgumentParser`.
|
||||||
|
|
||||||
This module is primarily used to auto-generate command argument definitions from
|
This module is primarily used to auto-generate command argument definitions from
|
||||||
@@ -20,8 +19,7 @@ def infer_args_from_func(
|
|||||||
func: Callable[[Any], Any] | None,
|
func: Callable[[Any], Any] | None,
|
||||||
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
|
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> 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,
|
This utility inspects the parameters of a function and returns a list of dictionaries,
|
||||||
each of which can be passed to `CommandArgumentParser.add_argument()`.
|
each of which can be passed to `CommandArgumentParser.add_argument()`.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# 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
|
This module provides type coercion functions for converting string input into expected
|
||||||
Python types, including `Enum`, `bool`, `datetime`, and `Literal`. It also supports
|
Python types, including `Enum`, `bool`, `datetime`, and `Literal`. It also supports
|
||||||
@@ -24,9 +23,18 @@ from falyx.logger import logger
|
|||||||
from falyx.parser.signature import infer_args_from_func
|
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:
|
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.
|
Accepts various truthy and falsy representations such as 'true', 'yes', '0', 'off', etc.
|
||||||
|
|
||||||
@@ -47,8 +55,7 @@ def coerce_bool(value: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def coerce_enum(value: Any, enum_type: EnumMeta) -> Any:
|
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.
|
Tries to resolve by name, value, or coerced base type.
|
||||||
|
|
||||||
@@ -81,8 +88,7 @@ def coerce_enum(value: Any, enum_type: EnumMeta) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
def coerce_value(value: str, target_type: type) -> Any:
|
def coerce_value(value: str, target_type: type) -> Any:
|
||||||
"""
|
"""Attempt to convert a string to the given target type.
|
||||||
Attempt to convert a string to the given target type.
|
|
||||||
|
|
||||||
Handles complex typing constructs such as Union, Literal, Enum, and datetime.
|
Handles complex typing constructs such as Union, Literal, Enum, and datetime.
|
||||||
|
|
||||||
@@ -133,8 +139,7 @@ def same_argument_definitions(
|
|||||||
actions: list[Any],
|
actions: list[Any],
|
||||||
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
|
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
|
||||||
) -> list[dict[str, Any]] | 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
|
This is used to infer whether actions in an ActionGroup or ProcessPool can share
|
||||||
a unified argument parser.
|
a unified argument parser.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Utilities for user interaction prompts in the Falyx CLI framework.
|
||||||
Utilities for user interaction prompts in the Falyx CLI framework.
|
|
||||||
|
|
||||||
Provides asynchronous confirmation dialogs and helper logic to determine
|
Provides asynchronous confirmation dialogs and helper logic to determine
|
||||||
whether a user should be prompted based on command-line options.
|
whether a user should be prompted based on command-line options.
|
||||||
@@ -9,6 +8,8 @@ Includes:
|
|||||||
- `should_prompt_user()` for conditional prompt logic.
|
- `should_prompt_user()` for conditional prompt logic.
|
||||||
- `confirm_async()` for interactive yes/no confirmation.
|
- `confirm_async()` for interactive yes/no confirmation.
|
||||||
"""
|
"""
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
from prompt_toolkit import PromptSession
|
from prompt_toolkit import PromptSession
|
||||||
from prompt_toolkit.formatted_text import (
|
from prompt_toolkit.formatted_text import (
|
||||||
@@ -25,18 +26,53 @@ from falyx.themes import OneColors
|
|||||||
from falyx.validators import yes_no_validator
|
from falyx.validators import yes_no_validator
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def prompt_session_context(session: PromptSession) -> Iterator[PromptSession]:
|
||||||
|
"""Temporary override for prompt session management"""
|
||||||
|
message = session.message
|
||||||
|
validator = session.validator
|
||||||
|
placeholder = session.placeholder
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
session.message = message
|
||||||
|
session.validator = validator
|
||||||
|
session.placeholder = placeholder
|
||||||
|
|
||||||
|
|
||||||
def should_prompt_user(
|
def should_prompt_user(
|
||||||
*,
|
*,
|
||||||
confirm: bool,
|
confirm: bool,
|
||||||
options: OptionsManager,
|
options: OptionsManager,
|
||||||
namespace: str = "cli_args",
|
namespace: str = "root",
|
||||||
):
|
override_namespace: str = "execution",
|
||||||
"""
|
) -> bool:
|
||||||
Determine whether to prompt the user for confirmation based on command
|
"""Determine whether to prompt the user for confirmation.
|
||||||
and global options.
|
|
||||||
|
Checks the `confirm` flag and consults the `OptionsManager` for any relevant
|
||||||
|
flags that may override the need for confirmation, such as `--never-prompt`,
|
||||||
|
`--force-confirm`, or `--skip-confirm`. The `override_namespace` is checked
|
||||||
|
first for any explicit overrides, followed by the main `namespace` for defaults.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
confirm (bool): The initial confirmation flag (e.g., from a command argument).
|
||||||
|
options (OptionsManager): The options manager to check for override flags.
|
||||||
|
namespace (str): The primary namespace to check for options (default: "root").
|
||||||
|
override_namespace (str): The secondary namespace for overrides (default: "execution").
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the user should be prompted, False if confirmation can be bypassed.
|
||||||
"""
|
"""
|
||||||
|
never_prompt = options.get("never_prompt", None, override_namespace)
|
||||||
|
if never_prompt is None:
|
||||||
never_prompt = options.get("never_prompt", False, namespace)
|
never_prompt = options.get("never_prompt", False, namespace)
|
||||||
|
|
||||||
|
force_confirm = options.get("force_confirm", None, override_namespace)
|
||||||
|
if force_confirm is None:
|
||||||
force_confirm = options.get("force_confirm", False, namespace)
|
force_confirm = options.get("force_confirm", False, namespace)
|
||||||
|
|
||||||
|
skip_confirm = options.get("skip_confirm", None, override_namespace)
|
||||||
|
if skip_confirm is None:
|
||||||
skip_confirm = options.get("skip_confirm", False, namespace)
|
skip_confirm = options.get("skip_confirm", False, namespace)
|
||||||
|
|
||||||
if never_prompt or skip_confirm:
|
if never_prompt or skip_confirm:
|
||||||
@@ -62,9 +98,16 @@ async def confirm_async(
|
|||||||
|
|
||||||
|
|
||||||
def rich_text_to_prompt_text(text: Text | str | StyleAndTextTuples) -> StyleAndTextTuples:
|
def rich_text_to_prompt_text(text: Text | str | StyleAndTextTuples) -> StyleAndTextTuples:
|
||||||
"""
|
"""Convert a Rich Text object to prompt_toolkit formatted text.
|
||||||
Convert a Rich Text object to a list of (style, text) tuples
|
|
||||||
compatible with prompt_toolkit.
|
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 isinstance(text, list):
|
||||||
if all(isinstance(pair, tuple) and len(pair) == 2 for pair in text):
|
if all(isinstance(pair, tuple) and len(pair) == 2 for pair in text):
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Defines structural protocols for advanced Falyx features.
|
||||||
Defines structural protocols for advanced Falyx features.
|
|
||||||
|
|
||||||
These runtime-checkable `Protocol` classes specify the expected interfaces for:
|
These runtime-checkable `Protocol` classes specify the expected interfaces for:
|
||||||
- Factories that asynchronously return actions
|
- Factories that asynchronously return actions
|
||||||
@@ -29,4 +28,6 @@ class ActionFactoryProtocol(Protocol):
|
|||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class ArgParserProtocol(Protocol):
|
class ArgParserProtocol(Protocol):
|
||||||
def __call__(self, args: list[str]) -> tuple[tuple, dict]: ...
|
def __call__(
|
||||||
|
self, args: list[str]
|
||||||
|
) -> tuple[tuple, dict[str, Any], dict[str, Any]]: ...
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Implements retry logic for Falyx Actions using configurable retry policies.
|
||||||
Implements retry logic for Falyx Actions using configurable retry policies.
|
|
||||||
|
|
||||||
This module defines:
|
This module defines:
|
||||||
- `RetryPolicy`: A configurable model controlling retry behavior (delay, backoff, jitter).
|
- `RetryPolicy`: A configurable model controlling retry behavior (delay, backoff, jitter).
|
||||||
@@ -30,8 +29,7 @@ from falyx.logger import logger
|
|||||||
|
|
||||||
|
|
||||||
class RetryPolicy(BaseModel):
|
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:
|
This model controls whether an action should be retried on failure, and how:
|
||||||
- `max_retries`: Maximum number of retry attempts.
|
- `max_retries`: Maximum number of retry attempts.
|
||||||
@@ -60,23 +58,16 @@ class RetryPolicy(BaseModel):
|
|||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
|
|
||||||
def enable_policy(self) -> None:
|
def enable_policy(self) -> None:
|
||||||
"""
|
"""Enable the retry policy."""
|
||||||
Enable the retry policy.
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
self.enabled = True
|
self.enabled = True
|
||||||
|
|
||||||
def is_active(self) -> bool:
|
def is_active(self) -> bool:
|
||||||
"""
|
"""Check if the retry policy is active."""
|
||||||
Check if the retry policy is active.
|
|
||||||
:return: True if the retry policy is active, False otherwise.
|
|
||||||
"""
|
|
||||||
return self.max_retries > 0 and self.enabled
|
return self.max_retries > 0 and self.enabled
|
||||||
|
|
||||||
|
|
||||||
class RetryHandler:
|
class RetryHandler:
|
||||||
"""
|
"""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
|
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
|
re-attempt the failed `Action`'s `action` method using the args/kwargs from
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Utilities for enabling retry behavior across Falyx actions.
|
||||||
Utilities for enabling retry behavior across Falyx actions.
|
|
||||||
|
|
||||||
This module provides a helper to recursively apply a `RetryPolicy` to an action and its
|
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
|
nested children (e.g. `ChainedAction`, `ActionGroup`), and register the appropriate
|
||||||
|
|||||||
95
falyx/routing.py
Normal file
95
falyx/routing.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
|
"""Routing result models for the Falyx CLI framework.
|
||||||
|
|
||||||
|
This module defines the core types used to describe the outcome of namespace
|
||||||
|
routing in a `Falyx` application.
|
||||||
|
|
||||||
|
It provides:
|
||||||
|
|
||||||
|
- `RouteKind`, an enum describing the kind of routed target that was reached,
|
||||||
|
such as a leaf command, namespace help, namespace TLDR, namespace menu, or
|
||||||
|
an unknown entry.
|
||||||
|
- `RouteResult`, a structured value object that captures the resolved routing
|
||||||
|
state, including the active namespace, invocation context, optional leaf
|
||||||
|
command, remaining argv for command-local parsing, and any suggestions for
|
||||||
|
unresolved input.
|
||||||
|
|
||||||
|
These types sit at the boundary between routing and execution. They do not
|
||||||
|
perform routing themselves. Instead, they are produced by Falyx routing logic
|
||||||
|
and then consumed by help rendering, completion, validation, preview, and
|
||||||
|
command dispatch flows.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from falyx.context import InvocationContext
|
||||||
|
from falyx.namespace import FalyxNamespace
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from falyx.command import Command
|
||||||
|
from falyx.falyx import Falyx
|
||||||
|
|
||||||
|
|
||||||
|
class RouteKind(Enum):
|
||||||
|
"""Enumerates the possible outcomes of Falyx namespace routing.
|
||||||
|
|
||||||
|
`RouteKind` identifies what the routing layer resolved the current input
|
||||||
|
to, allowing downstream code to decide whether it should execute a command,
|
||||||
|
render namespace help, show TLDR output, display a namespace menu, or
|
||||||
|
surface an unknown-entry message.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
COMMAND: Routing reached a leaf command that may be parsed and executed.
|
||||||
|
NAMESPACE_MENU: Routing stopped at a namespace menu target.
|
||||||
|
NAMESPACE_HELP: Routing resolved to namespace help output.
|
||||||
|
NAMESPACE_TLDR: Routing resolved to namespace TLDR output.
|
||||||
|
UNKNOWN: Routing failed to resolve the requested entry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
COMMAND = "command"
|
||||||
|
NAMESPACE_MENU = "namespace_menu"
|
||||||
|
NAMESPACE_HELP = "namespace_help"
|
||||||
|
NAMESPACE_TLDR = "namespace_tldr"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class RouteResult:
|
||||||
|
"""Represents the resolved output of a Falyx routing operation.
|
||||||
|
|
||||||
|
`RouteResult` captures the full state needed after namespace resolution
|
||||||
|
completes and before command execution or help rendering begins. It records
|
||||||
|
what kind of target was reached, where routing ended, the invocation path
|
||||||
|
used to reach it, and any leaf-command metadata needed for downstream
|
||||||
|
parsing.
|
||||||
|
|
||||||
|
This model is used by Falyx execution, help, preview, completion, and
|
||||||
|
validation flows to make routing decisions explicit and easy to inspect.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
kind: The type of routed result that was resolved.
|
||||||
|
namespace: The `Falyx` namespace where routing ended.
|
||||||
|
context: Invocation context describing the routed path and current mode.
|
||||||
|
command: Resolved leaf command, if routing ended at a command.
|
||||||
|
namespace_entry: Resolved namespace entry, if the route corresponds to a
|
||||||
|
specific nested namespace.
|
||||||
|
leaf_argv: Remaining argv that should be delegated to the resolved
|
||||||
|
command's local parser.
|
||||||
|
current_head: The current head token that routing is evaluating, used for
|
||||||
|
generating suggestions.
|
||||||
|
suggestions: Suggested entry names for unresolved input.
|
||||||
|
is_preview: Whether the routed invocation is in preview mode.
|
||||||
|
"""
|
||||||
|
|
||||||
|
kind: RouteKind
|
||||||
|
namespace: "Falyx"
|
||||||
|
context: InvocationContext
|
||||||
|
command: "Command | None" = None
|
||||||
|
namespace_entry: FalyxNamespace | None = None
|
||||||
|
leaf_argv: list[str] = field(default_factory=list)
|
||||||
|
current_head: str = ""
|
||||||
|
suggestions: list[str] = field(default_factory=list)
|
||||||
|
is_preview: bool = False
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Provides interactive selection utilities for Falyx CLI actions.
|
||||||
Provides interactive selection utilities for Falyx CLI actions.
|
|
||||||
|
|
||||||
This module defines `SelectionOption` objects, selection maps, and rich-powered
|
This module defines `SelectionOption` objects, selection maps, and rich-powered
|
||||||
rendering functions to build interactive selection prompts using `prompt_toolkit`.
|
rendering functions to build interactive selection prompts using `prompt_toolkit`.
|
||||||
@@ -21,7 +20,7 @@ from rich.markup import escape
|
|||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
from falyx.console import console
|
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.themes import OneColors
|
||||||
from falyx.utils import CaseInsensitiveDict, chunks
|
from falyx.utils import CaseInsensitiveDict, chunks
|
||||||
from falyx.validators import MultiIndexValidator, MultiKeyValidator
|
from falyx.validators import MultiIndexValidator, MultiKeyValidator
|
||||||
@@ -46,9 +45,7 @@ class SelectionOption:
|
|||||||
|
|
||||||
|
|
||||||
class SelectionOptionMap(CaseInsensitiveDict):
|
class SelectionOptionMap(CaseInsensitiveDict):
|
||||||
"""
|
"""Manages selection options including validation and reserved key protection."""
|
||||||
Manages selection options including validation and reserved key protection.
|
|
||||||
"""
|
|
||||||
|
|
||||||
RESERVED_KEYS: set[str] = set()
|
RESERVED_KEYS: set[str] = set()
|
||||||
|
|
||||||
@@ -118,6 +115,7 @@ def render_table_base(
|
|||||||
highlight: bool = True,
|
highlight: bool = True,
|
||||||
column_names: Sequence[str] | None = None,
|
column_names: Sequence[str] | None = None,
|
||||||
) -> Table:
|
) -> Table:
|
||||||
|
"""Render the base table for selection prompts."""
|
||||||
table = Table(
|
table = Table(
|
||||||
title=title,
|
title=title,
|
||||||
caption=caption,
|
caption=caption,
|
||||||
@@ -288,12 +286,25 @@ async def prompt_for_index(
|
|||||||
allow_duplicates: bool = False,
|
allow_duplicates: bool = False,
|
||||||
cancel_key: str = "",
|
cancel_key: str = "",
|
||||||
) -> int | list[int]:
|
) -> int | list[int]:
|
||||||
|
"""Prompt the user to select an index from a table of options. Return the selected index."""
|
||||||
prompt_session = prompt_session or PromptSession()
|
prompt_session = prompt_session or PromptSession()
|
||||||
|
|
||||||
if show_table:
|
if show_table:
|
||||||
console.print(table, justify="center")
|
console.print(table, justify="center")
|
||||||
|
|
||||||
selection = await prompt_session.prompt_async(
|
number_selections_str = (
|
||||||
|
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),
|
message=rich_text_to_prompt_text(prompt_message),
|
||||||
validator=MultiIndexValidator(
|
validator=MultiIndexValidator(
|
||||||
min_index,
|
min_index,
|
||||||
@@ -304,6 +315,7 @@ async def prompt_for_index(
|
|||||||
cancel_key,
|
cancel_key,
|
||||||
),
|
),
|
||||||
default=default_selection,
|
default=default_selection,
|
||||||
|
placeholder=placeholder,
|
||||||
)
|
)
|
||||||
|
|
||||||
if selection.strip() == cancel_key:
|
if selection.strip() == cancel_key:
|
||||||
@@ -332,12 +344,25 @@ async def prompt_for_selection(
|
|||||||
if show_table:
|
if show_table:
|
||||||
console.print(table, justify="center")
|
console.print(table, justify="center")
|
||||||
|
|
||||||
selected = await prompt_session.prompt_async(
|
number_selections_str = (
|
||||||
|
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),
|
message=rich_text_to_prompt_text(prompt_message),
|
||||||
validator=MultiKeyValidator(
|
validator=MultiKeyValidator(
|
||||||
keys, number_selections, separator, allow_duplicates, cancel_key
|
keys, number_selections, separator, allow_duplicates, cancel_key
|
||||||
),
|
),
|
||||||
default=default_selection,
|
default=default_selection,
|
||||||
|
placeholder=placeholder,
|
||||||
)
|
)
|
||||||
|
|
||||||
if selected.strip() == cancel_key:
|
if selected.strip() == cancel_key:
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Defines flow control signals used internally by the Falyx CLI framework.
|
||||||
Defines flow control signals used internally by the Falyx CLI framework.
|
|
||||||
|
|
||||||
These signals are raised to interrupt or redirect CLI execution flow
|
These signals are raised to interrupt or redirect CLI execution flow
|
||||||
(e.g., returning to a menu, quitting, or displaying help) without
|
(e.g., returning to a menu, quitting, or displaying help) without
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Centralized spinner rendering for Falyx CLI.
|
||||||
Centralized spinner rendering for Falyx CLI.
|
|
||||||
|
|
||||||
This module provides the `SpinnerManager` class, which manages a collection of
|
This module provides the `SpinnerManager` class, which manages a collection of
|
||||||
Rich spinners that can be displayed concurrently during long-running tasks.
|
Rich spinners that can be displayed concurrently during long-running tasks.
|
||||||
@@ -55,8 +54,7 @@ from falyx.themes import OneColors
|
|||||||
|
|
||||||
|
|
||||||
class SpinnerData:
|
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
|
This class is a lightweight container for spinner metadata, storing the
|
||||||
message text, spinner type, style, and speed. It also initializes the
|
message text, spinner type, style, and speed. It also initializes the
|
||||||
@@ -92,8 +90,7 @@ class SpinnerData:
|
|||||||
|
|
||||||
|
|
||||||
class SpinnerManager:
|
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
|
SpinnerManager maintains a registry of active spinners and a single
|
||||||
Rich `Live` display loop to render them. When the first spinner is added,
|
Rich `Live` display loop to render them. When the first spinner is added,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Generates a Rich table view of Falyx commands grouped by their tags.
|
||||||
Generates a Rich table view of Falyx commands grouped by their tags.
|
|
||||||
|
|
||||||
This module defines a utility function for rendering a custom CLI command
|
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
|
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
|
# Group commands by first tag
|
||||||
grouped: dict[str, list[Command]] = defaultdict(list)
|
grouped: dict[str, list[Command]] = defaultdict(list)
|
||||||
for cmd in flx.commands.values():
|
for command in flx.commands.values():
|
||||||
first_tag = cmd.tags[0] if cmd.tags else "Other"
|
first_tag = command.tags[0] if command.tags else "Other"
|
||||||
grouped[first_tag.capitalize()].append(cmd)
|
grouped[first_tag.capitalize()].append(command)
|
||||||
|
|
||||||
# Add grouped commands to table
|
# Add grouped commands to table
|
||||||
for group_name, commands in grouped.items():
|
for group_name, commands in grouped.items():
|
||||||
table.add_row(f"[bold underline]{group_name} Commands[/]")
|
table.add_row(f"[bold underline]{group_name} Commands[/]")
|
||||||
for cmd in commands:
|
for command in commands:
|
||||||
table.add_row(f"[{cmd.key}] [{cmd.style}]{cmd.description}")
|
table.add_row(f"[{command.key}] [{command.style}]{command.description}")
|
||||||
table.add_row("")
|
table.add_row("")
|
||||||
|
|
||||||
# Add bottom row
|
# Add bottom row
|
||||||
for row in flx.get_bottom_row():
|
for row in flx._get_bottom_row():
|
||||||
table.add_row(row)
|
table.add_row(row)
|
||||||
|
|
||||||
return table
|
return table
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""
|
"""Falyx CLI Framework
|
||||||
Falyx CLI Framework
|
|
||||||
|
|
||||||
Copyright (c) 2025 rtj.dev LLC.
|
Copyright (c) 2026 rtj.dev LLC.
|
||||||
Licensed under the MIT License. See LICENSE file for details.
|
Licensed under the MIT License. See LICENSE file for details.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""A Python module that integrates the Nord color palette with the Rich library.
|
||||||
A Python module that integrates the Nord color palette with the Rich library.
|
|
||||||
It defines a metaclass-based NordColors class allowing dynamic attribute lookups
|
It defines a metaclass-based NordColors class allowing dynamic attribute lookups
|
||||||
(e.g., NORD12bu -> "#D08770 bold underline") and provides a comprehensive Nord-based
|
(e.g., NORD12bu -> "#D08770 bold underline") and provides a comprehensive Nord-based
|
||||||
Theme that customizes Rich's default styles.
|
Theme that customizes Rich's default styles.
|
||||||
@@ -26,8 +25,7 @@ from rich.theme import Theme
|
|||||||
|
|
||||||
|
|
||||||
class ColorsMeta(type):
|
class ColorsMeta(type):
|
||||||
"""
|
"""A metaclass that catches attribute lookups like `NORD12buidrs` or `ORANGE_b` and returns
|
||||||
A metaclass that catches attribute lookups like `NORD12buidrs` or `ORANGE_b` and returns
|
|
||||||
a string combining the base color + bold/italic/underline/dim/reverse/strike flags.
|
a string combining the base color + bold/italic/underline/dim/reverse/strike flags.
|
||||||
|
|
||||||
The color values are required to be uppercase with optional underscores and digits,
|
The color values are required to be uppercase with optional underscores and digits,
|
||||||
@@ -152,8 +150,7 @@ class OneColors(metaclass=ColorsMeta):
|
|||||||
|
|
||||||
|
|
||||||
class NordColors(metaclass=ColorsMeta):
|
class NordColors(metaclass=ColorsMeta):
|
||||||
"""
|
"""Defines the Nord color palette as class attributes.
|
||||||
Defines the Nord color palette as class attributes.
|
|
||||||
|
|
||||||
Each color is labeled by its canonical Nord name (NORD0-NORD15)
|
Each color is labeled by its canonical Nord name (NORD0-NORD15)
|
||||||
and also has useful aliases grouped by theme:
|
and also has useful aliases grouped by theme:
|
||||||
@@ -212,8 +209,7 @@ class NordColors(metaclass=ColorsMeta):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def as_dict(cls):
|
def as_dict(cls):
|
||||||
"""
|
"""Returns a dictionary mapping every NORD* attribute
|
||||||
Returns a dictionary mapping every NORD* attribute
|
|
||||||
(e.g. 'NORD0') to its hex code.
|
(e.g. 'NORD0') to its hex code.
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
@@ -224,8 +220,7 @@ class NordColors(metaclass=ColorsMeta):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def aliases(cls):
|
def aliases(cls):
|
||||||
"""
|
"""Returns a dictionary of *all* other aliases
|
||||||
Returns a dictionary of *all* other aliases
|
|
||||||
(Polar Night, Snow Storm, Frost, Aurora).
|
(Polar Night, Snow Storm, Frost, Aurora).
|
||||||
"""
|
"""
|
||||||
skip_prefixes = ("NORD", "__")
|
skip_prefixes = ("NORD", "__")
|
||||||
@@ -462,9 +457,7 @@ NORD_THEME_STYLES: dict[str, Style] = {
|
|||||||
|
|
||||||
|
|
||||||
def get_nord_theme() -> Theme:
|
def get_nord_theme() -> Theme:
|
||||||
"""
|
"""Returns a Rich Theme for the Nord color palette."""
|
||||||
Returns a Rich Theme for the Nord color palette.
|
|
||||||
"""
|
|
||||||
return Theme(NORD_THEME_STYLES)
|
return Theme(NORD_THEME_STYLES)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""General-purpose utilities and helpers for the Falyx CLI framework.
|
||||||
General-purpose utilities and helpers for the Falyx CLI framework.
|
|
||||||
|
|
||||||
This module includes asynchronous wrappers, logging setup, formatting utilities,
|
This module includes asynchronous wrappers, logging setup, formatting utilities,
|
||||||
and small type-safe enhancements such as `CaseInsensitiveDict` and coroutine enforcement.
|
and small type-safe enhancements such as `CaseInsensitiveDict` and coroutine enforcement.
|
||||||
@@ -130,8 +129,7 @@ def setup_logging(
|
|||||||
file_log_level: int = logging.DEBUG,
|
file_log_level: int = logging.DEBUG,
|
||||||
console_log_level: int = logging.WARNING,
|
console_log_level: int = logging.WARNING,
|
||||||
):
|
):
|
||||||
"""
|
"""Configure logging for Falyx with support for both CLI-friendly and structured
|
||||||
Configure logging for Falyx with support for both CLI-friendly and structured
|
|
||||||
JSON output.
|
JSON output.
|
||||||
|
|
||||||
This function sets up separate logging handlers for console and file output,
|
This function sets up separate logging handlers for console and file output,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||||
"""
|
"""Input validators for use with Prompt Toolkit and interactive Falyx CLI workflows.
|
||||||
Input validators for use with Prompt Toolkit and interactive Falyx CLI workflows.
|
|
||||||
|
|
||||||
This module defines reusable `Validator` instances and subclasses that enforce valid
|
This module defines reusable `Validator` instances and subclasses that enforce valid
|
||||||
user input during prompts—especially for selection actions, confirmations, and
|
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 prompt_toolkit.validation import ValidationError, Validator
|
||||||
|
|
||||||
|
from falyx.routing import RouteKind
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from falyx.falyx import Falyx
|
from falyx.falyx import Falyx
|
||||||
|
|
||||||
@@ -48,10 +49,33 @@ class CommandValidator(Validator):
|
|||||||
message=self.error_message,
|
message=self.error_message,
|
||||||
cursor_position=len(text),
|
cursor_position=len(text),
|
||||||
)
|
)
|
||||||
is_preview, choice, _, __ = await self.falyx.get_command(text, from_validate=True)
|
route, _, __, ___ = await self.falyx.prepare_route(text, from_validate=True)
|
||||||
if is_preview:
|
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
|
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(
|
raise ValidationError(
|
||||||
message=self.error_message,
|
message=self.error_message,
|
||||||
cursor_position=len(text),
|
cursor_position=len(text),
|
||||||
@@ -132,6 +156,8 @@ def word_validator(word: str) -> Validator:
|
|||||||
|
|
||||||
|
|
||||||
class MultiIndexValidator(Validator):
|
class MultiIndexValidator(Validator):
|
||||||
|
"""Validator for multiple index selections (e.g. '1,2,3')."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
minimum: int,
|
minimum: int,
|
||||||
@@ -182,6 +208,8 @@ class MultiIndexValidator(Validator):
|
|||||||
|
|
||||||
|
|
||||||
class MultiKeyValidator(Validator):
|
class MultiKeyValidator(Validator):
|
||||||
|
"""Validator for multiple key selections (e.g. 'A,B,C')."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
keys: Sequence[str] | KeysView[str],
|
keys: Sequence[str] | KeysView[str],
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.1.87"
|
__version__ = "0.2.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "falyx"
|
name = "falyx"
|
||||||
version = "0.1.87"
|
version = "0.2.0"
|
||||||
description = "Reliable and introspectable async CLI action framework."
|
description = "Reliable and introspectable async CLI action framework."
|
||||||
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
|
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
100
tests/test_actions/test_load_file_action.py
Normal file
100
tests/test_actions/test_load_file_action.py
Normal 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
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
# test_command.py
|
# test_command.py
|
||||||
import pytest
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from falyx.action import Action, BaseIOAction, ChainedAction
|
from falyx.action import Action, BaseIOAction, ChainedAction
|
||||||
from falyx.command import Command
|
from falyx.command import Command
|
||||||
@@ -172,3 +173,15 @@ def test_command_bad_action():
|
|||||||
with pytest.raises(TypeError) as exc_info:
|
with pytest.raises(TypeError) as exc_info:
|
||||||
Command(key="TEST", description="Test Command", action="not_callable")
|
Command(key="TEST", description="Test Command", action="not_callable")
|
||||||
assert str(exc_info.value) == "Action must be a callable or an instance of BaseAction"
|
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)
|
||||||
|
|||||||
@@ -1,97 +1,305 @@
|
|||||||
from types import SimpleNamespace
|
import re
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from prompt_toolkit.completion import Completion
|
from prompt_toolkit.completion import Completion
|
||||||
from prompt_toolkit.document import Document
|
from prompt_toolkit.document import Document
|
||||||
|
|
||||||
|
from falyx import Falyx
|
||||||
from falyx.completer import FalyxCompleter
|
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
|
@pytest.fixture
|
||||||
def fake_falyx():
|
def falyx():
|
||||||
fake_arg_parser = SimpleNamespace(
|
flx = Falyx()
|
||||||
suggest_next=lambda tokens, end: ["--tag", "--name", "value with space"]
|
|
||||||
|
run_parser = CommandArgumentParser(
|
||||||
|
command_key="R",
|
||||||
|
command_description="Run Command",
|
||||||
)
|
)
|
||||||
fake_command = SimpleNamespace(key="R", aliases=["RUN"], arg_parser=fake_arg_parser)
|
run_parser.add_argument("--tag")
|
||||||
return SimpleNamespace(
|
run_parser.add_argument("--name")
|
||||||
exit_command=SimpleNamespace(key="X", aliases=["EXIT"]),
|
|
||||||
help_command=SimpleNamespace(key="H", aliases=["HELP"]),
|
flx.add_command(
|
||||||
history_command=SimpleNamespace(key="Y", aliases=["HISTORY"]),
|
"R",
|
||||||
commands={"R": fake_command},
|
"Run Command",
|
||||||
_name_map={"R": fake_command, "RUN": fake_command, "X": fake_command},
|
lambda: None,
|
||||||
|
aliases=["RUN"],
|
||||||
|
arg_parser=run_parser,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ops = Falyx(program="ops")
|
||||||
|
|
||||||
def test_suggest_commands(fake_falyx):
|
deploy_parser = CommandArgumentParser(
|
||||||
completer = FalyxCompleter(fake_falyx)
|
command_key="D",
|
||||||
completions = list(completer._suggest_commands("R"))
|
command_description="Deploy Command",
|
||||||
assert any(c.text == "R" for c in completions)
|
)
|
||||||
assert any(c.text == "RUN" for c in completions)
|
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_commands_empty(fake_falyx):
|
def test_suggest_namespace_entries_root(falyx):
|
||||||
completer = FalyxCompleter(fake_falyx)
|
completer = FalyxCompleter(falyx)
|
||||||
completions = list(completer._suggest_commands(""))
|
|
||||||
assert any(c.text == "X" for c in completions)
|
completions = completer._suggest_namespace_entries(falyx, "R")
|
||||||
assert any(c.text == "H" for c in completions)
|
|
||||||
|
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_commands_no_match(fake_falyx):
|
def test_suggest_namespace_entries_submenu(falyx):
|
||||||
completer = FalyxCompleter(fake_falyx)
|
completer = FalyxCompleter(falyx)
|
||||||
completions = list(completer._suggest_commands("Z"))
|
ops = falyx.namespaces["OPS"].namespace
|
||||||
assert not completions
|
|
||||||
|
completions = completer._suggest_namespace_entries(ops, "D")
|
||||||
|
|
||||||
|
assert "D" in completions
|
||||||
|
assert "DEPLOY" in completions
|
||||||
|
|
||||||
|
|
||||||
def test_get_completions_no_input(fake_falyx):
|
def test_get_completions_no_input_shows_root_entries(falyx):
|
||||||
completer = FalyxCompleter(fake_falyx)
|
completer = FalyxCompleter(falyx)
|
||||||
doc = Document("")
|
|
||||||
results = list(completer.get_completions(doc, None))
|
results = list(completer.get_completions(Document(""), None))
|
||||||
|
texts = completion_texts(results)
|
||||||
|
|
||||||
assert any(isinstance(c, Completion) for c in results)
|
assert any(isinstance(c, Completion) for c in results)
|
||||||
assert any(c.text == "X" for c in results)
|
assert "R" in texts
|
||||||
|
assert "OPS" in texts
|
||||||
|
assert "X" in texts
|
||||||
|
|
||||||
|
|
||||||
def test_get_completions_no_match(fake_falyx):
|
def test_get_completions_partial_root_entry(falyx):
|
||||||
completer = FalyxCompleter(fake_falyx)
|
completer = FalyxCompleter(falyx)
|
||||||
doc = Document("Z")
|
|
||||||
completions = list(completer.get_completions(doc, None))
|
results = list(completer.get_completions(Document("OP"), None))
|
||||||
assert not completions
|
texts = completion_texts(results)
|
||||||
doc = Document("Z Z")
|
|
||||||
completions = list(completer.get_completions(doc, None))
|
assert "OPS" in texts
|
||||||
assert not completions
|
assert "OPERATIONS" in texts
|
||||||
|
|
||||||
|
|
||||||
def test_get_completions_partial_command(fake_falyx):
|
def test_get_completions_no_match_returns_empty(falyx):
|
||||||
completer = FalyxCompleter(fake_falyx)
|
completer = FalyxCompleter(falyx)
|
||||||
doc = Document("R")
|
|
||||||
results = list(completer.get_completions(doc, None))
|
assert list(completer.get_completions(Document("Z"), None)) == []
|
||||||
assert any(c.text in ("R", "RUN") for c in results)
|
assert list(completer.get_completions(Document("OPS Z"), None)) == []
|
||||||
|
|
||||||
|
|
||||||
def test_get_completions_with_flag(fake_falyx):
|
def test_get_completions_namespace_boundary_suggests_help_flags(falyx):
|
||||||
completer = FalyxCompleter(fake_falyx)
|
completer = FalyxCompleter(falyx)
|
||||||
doc = Document("R ")
|
|
||||||
results = list(completer.get_completions(doc, None))
|
results = list(completer.get_completions(Document("OPS -"), None))
|
||||||
assert "--tag" in [c.text for c in results]
|
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_partial_flag(fake_falyx):
|
def test_get_completions_preview_prefix_is_preserved(falyx):
|
||||||
completer = FalyxCompleter(fake_falyx)
|
completer = FalyxCompleter(falyx)
|
||||||
doc = Document("R --t")
|
|
||||||
results = list(completer.get_completions(doc, None))
|
results = list(completer.get_completions(Document("?R"), None))
|
||||||
assert all(c.start_position <= 0 for c in results)
|
texts = completion_texts(results)
|
||||||
assert any(c.text.startswith("--t") or c.display == "--tag" for c in results)
|
|
||||||
|
assert any(text.startswith("?R") for text in texts)
|
||||||
|
|
||||||
|
|
||||||
def test_get_completions_bad_input(fake_falyx):
|
def test_get_completions_preview_prefix_for_namespace_entries(falyx):
|
||||||
completer = FalyxCompleter(fake_falyx)
|
completer = FalyxCompleter(falyx)
|
||||||
doc = Document('R "unclosed quote')
|
|
||||||
results = list(completer.get_completions(doc, None))
|
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 == []
|
assert results == []
|
||||||
|
|
||||||
|
|
||||||
def test_get_completions_exception_handling(fake_falyx):
|
def test_get_completions_exception_handling(falyx, monkeypatch):
|
||||||
completer = FalyxCompleter(fake_falyx)
|
completer = FalyxCompleter(falyx)
|
||||||
fake_falyx.commands["R"].arg_parser.suggest_next = lambda *args: 1 / 0
|
|
||||||
doc = Document("R --tag")
|
def boom(*args, **kwargs):
|
||||||
results = list(completer.get_completions(doc, None))
|
raise ZeroDivisionError("boom")
|
||||||
|
|
||||||
|
monkeypatch.setattr(falyx.commands["R"].arg_parser, "suggest_next", boom)
|
||||||
|
|
||||||
|
results = list(completer.get_completions(Document("R --tag"), None))
|
||||||
|
|
||||||
assert results == []
|
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
|
||||||
|
|||||||
@@ -1,38 +1,42 @@
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from prompt_toolkit.document import Document
|
|
||||||
|
|
||||||
from falyx.completer import FalyxCompleter
|
from falyx.completer import FalyxCompleter
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
def completion_texts(completions) -> list[str]:
|
||||||
def fake_falyx():
|
return [c.text for c in completions]
|
||||||
fake_arg_parser = SimpleNamespace(
|
|
||||||
suggest_next=lambda tokens, end: ["AETHERWARP", "AETHERZOOM"]
|
|
||||||
)
|
|
||||||
fake_command = SimpleNamespace(key="R", aliases=["RUN"], arg_parser=fake_arg_parser)
|
|
||||||
return SimpleNamespace(
|
|
||||||
exit_command=SimpleNamespace(key="X", aliases=["EXIT"]),
|
|
||||||
help_command=SimpleNamespace(key="H", aliases=["HELP"]),
|
|
||||||
history_command=SimpleNamespace(key="Y", aliases=["HISTORY"]),
|
|
||||||
commands={"R": fake_command},
|
|
||||||
_name_map={"R": fake_command, "RUN": fake_command, "X": fake_command},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_lcp_completions(fake_falyx):
|
def test_lcp_completions():
|
||||||
completer = FalyxCompleter(fake_falyx)
|
completer = FalyxCompleter(SimpleNamespace())
|
||||||
doc = Document("R A")
|
suggestions = ["AETHERWARP", "AETHERZOOM"]
|
||||||
results = list(completer.get_completions(doc, None))
|
stub = "A"
|
||||||
assert any(c.text == "AETHER" for c in results)
|
completions = list(completer._yield_lcp_completions(suggestions, stub))
|
||||||
assert any(c.text == "AETHERWARP" for c in results)
|
texts = completion_texts(completions)
|
||||||
assert any(c.text == "AETHERZOOM" for c in results)
|
|
||||||
|
assert "AETHER" in texts
|
||||||
|
assert "AETHERWARP" in texts
|
||||||
|
assert "AETHERZOOM" in texts
|
||||||
|
|
||||||
|
|
||||||
def test_lcp_completions_space(fake_falyx):
|
def test_lcp_completions_space():
|
||||||
completer = FalyxCompleter(fake_falyx)
|
completer = FalyxCompleter(SimpleNamespace())
|
||||||
suggestions = ["London", "New York", "San Francisco"]
|
suggestions = ["London", "New York", "San Francisco"]
|
||||||
stub = "N"
|
stub = "N"
|
||||||
completions = list(completer._yield_lcp_completions(suggestions, stub))
|
completions = list(completer._yield_lcp_completions(suggestions, stub))
|
||||||
assert any(c.text == '"New York"' for c in completions)
|
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
|
||||||
|
|||||||
30
tests/test_execution_option.py
Normal file
30
tests/test_execution_option.py
Normal 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")
|
||||||
@@ -5,7 +5,7 @@ from falyx.action import Action
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_key():
|
async def test_execute_command():
|
||||||
"""Test if Falyx can run in run key mode."""
|
"""Test if Falyx can run in run key mode."""
|
||||||
falyx = Falyx("Run Key Test")
|
falyx = Falyx("Run Key Test")
|
||||||
|
|
||||||
@@ -17,12 +17,12 @@ async def test_run_key():
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Run the CLI
|
# Run the CLI
|
||||||
result = await falyx.run_key("T")
|
result = await falyx.execute_command("T")
|
||||||
assert result == "Hello, World!"
|
assert result == "Hello, World!"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_key_recover():
|
async def test_execute_command_recover():
|
||||||
"""Test if Falyx can recover from a failure in run key mode."""
|
"""Test if Falyx can recover from a failure in run key mode."""
|
||||||
falyx = Falyx("Run Key Recovery Test")
|
falyx = Falyx("Run Key Recovery Test")
|
||||||
|
|
||||||
@@ -42,5 +42,5 @@ async def test_run_key_recover():
|
|||||||
retry=True,
|
retry=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await falyx.run_key("E")
|
result = await falyx.execute_command("E")
|
||||||
assert result == "ok"
|
assert result == "ok"
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
from falyx import Falyx
|
from falyx import Falyx
|
||||||
|
from falyx.exceptions import CommandArgumentError
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -8,7 +10,7 @@ async def test_help_command(capsys):
|
|||||||
flx = Falyx()
|
flx = Falyx()
|
||||||
assert flx.help_command.arg_parser.aliases[0] == "HELP"
|
assert flx.help_command.arg_parser.aliases[0] == "HELP"
|
||||||
assert flx.help_command.arg_parser.command_key == "H"
|
assert flx.help_command.arg_parser.command_key == "H"
|
||||||
await flx.run_key("H")
|
await flx.execute_command("H")
|
||||||
|
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert "Show this help menu" in captured.out
|
assert "Show this help menu" in captured.out
|
||||||
@@ -28,7 +30,7 @@ async def test_help_command_with_new_command(capsys):
|
|||||||
aliases=["TEST"],
|
aliases=["TEST"],
|
||||||
help_text="This is a new command.",
|
help_text="This is a new command.",
|
||||||
)
|
)
|
||||||
await flx.run_key("H")
|
await flx.execute_command("H")
|
||||||
|
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert "This is a new command." in captured.out
|
assert "This is a new command." in captured.out
|
||||||
@@ -49,7 +51,7 @@ async def test_render_help(capsys):
|
|||||||
aliases=["SC"],
|
aliases=["SC"],
|
||||||
help_text="This is a sample command.",
|
help_text="This is a sample command.",
|
||||||
)
|
)
|
||||||
await flx._render_help()
|
await flx.render_help()
|
||||||
|
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert "This is a sample command." in captured.out
|
assert "This is a sample command." in captured.out
|
||||||
@@ -70,27 +72,24 @@ async def test_help_command_by_tag(capsys):
|
|||||||
tags=["tag1"],
|
tags=["tag1"],
|
||||||
help_text="This command is tagged.",
|
help_text="This command is tagged.",
|
||||||
)
|
)
|
||||||
await flx.run_key("H", args=("tag1",))
|
await flx.execute_command("H -t tag1")
|
||||||
|
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert "tag1" in captured.out
|
text = Text.from_ansi(captured.out)
|
||||||
assert "This command is tagged." in captured.out
|
assert "tag1" in text.plain
|
||||||
assert "HELP" not in captured.out
|
assert "This command is tagged." in text.plain
|
||||||
|
assert "HELP" not in text.plain
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_help_command_empty_tags(capsys):
|
async def test_help_command_bad_argument(capsys):
|
||||||
flx = Falyx()
|
flx = Falyx()
|
||||||
|
|
||||||
async def untagged_command(falyx: Falyx):
|
async def untagged_command(falyx: Falyx):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
flx.add_command(
|
flx.add_command("U", "Untagged Command", untagged_command)
|
||||||
"U", "Untagged Command", untagged_command, help_text="This command has no tags."
|
with pytest.raises(
|
||||||
)
|
CommandArgumentError, match="Unexpected positional argument: nonexistent_tag"
|
||||||
await flx.run_key("H", args=("nonexistent_tag",))
|
):
|
||||||
|
await flx.execute_command("H nonexistent_tag")
|
||||||
captured = capsys.readouterr()
|
|
||||||
print(captured.out)
|
|
||||||
assert "nonexistent_tag" in captured.out
|
|
||||||
assert "Nothing to show here" in captured.out
|
|
||||||
|
|||||||
0
tests/test_falyx/test_routing.py
Normal file
0
tests/test_falyx/test_routing.py
Normal file
@@ -1,19 +1,255 @@
|
|||||||
|
import asyncio
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
from falyx import Falyx
|
from falyx import Falyx
|
||||||
from falyx.parser import get_arg_parsers
|
from falyx.console import console as falyx_console
|
||||||
|
from falyx.exceptions import FalyxError
|
||||||
|
from falyx.parser import ParseResult
|
||||||
|
from falyx.signals import BackSignal, CancelSignal, FlowSignal, HelpSignal, QuitSignal
|
||||||
|
|
||||||
|
|
||||||
|
async def throw_error_action(error: str):
|
||||||
|
if error == "QuitSignal":
|
||||||
|
raise QuitSignal("Quit signal triggered.")
|
||||||
|
elif error == "BackSignal":
|
||||||
|
raise BackSignal("Back signal triggered.")
|
||||||
|
elif error == "CancelSignal":
|
||||||
|
raise CancelSignal("Cancel signal triggered.")
|
||||||
|
elif error == "ValueError":
|
||||||
|
raise ValueError("This is a ValueError.")
|
||||||
|
elif error == "HelpSignal":
|
||||||
|
raise HelpSignal("Help signal triggered.")
|
||||||
|
elif error == "FalyxError":
|
||||||
|
raise FalyxError("This is a FalyxError.")
|
||||||
|
elif error == "FlowSignal":
|
||||||
|
raise FlowSignal("Flow signal triggered.")
|
||||||
|
else:
|
||||||
|
raise asyncio.CancelledError("An error occurred in the action.")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def flx() -> Falyx:
|
||||||
|
sys.argv = ["falyx", "T"]
|
||||||
|
flx = Falyx()
|
||||||
|
flx.add_command(
|
||||||
|
"T",
|
||||||
|
"Test",
|
||||||
|
action=lambda: "hello",
|
||||||
|
)
|
||||||
|
flx.add_tldr_example(
|
||||||
|
entry_key="T",
|
||||||
|
usage="",
|
||||||
|
description="This is a TLDR example for the T command.",
|
||||||
|
)
|
||||||
|
return flx
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def flx_with_submenu() -> Falyx:
|
||||||
|
flx = Falyx()
|
||||||
|
submenu = Falyx("Submenu")
|
||||||
|
submenu.add_command(
|
||||||
|
"T",
|
||||||
|
"Test",
|
||||||
|
action=lambda: "hello from submenu",
|
||||||
|
)
|
||||||
|
submenu.add_tldr_example(
|
||||||
|
entry_key="T",
|
||||||
|
usage="",
|
||||||
|
description="This is a TLDR example for the T command in the submenu.",
|
||||||
|
)
|
||||||
|
flx.add_submenu(
|
||||||
|
"S",
|
||||||
|
"Submenu",
|
||||||
|
submenu=submenu,
|
||||||
|
)
|
||||||
|
return flx
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_basic(capsys):
|
async def test_run_basic(capsys):
|
||||||
sys.argv = ["falyx", "run", "-h"]
|
sys.argv = ["falyx", "-h"]
|
||||||
falyx_parsers = get_arg_parsers()
|
|
||||||
assert falyx_parsers is not None, "Falyx parsers should be initialized"
|
|
||||||
flx = Falyx()
|
flx = Falyx()
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
await flx.run(falyx_parsers)
|
await flx.run()
|
||||||
|
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert "Run a command by its key or alias." in captured.out
|
assert "Show this help menu." in captured.out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_default_to_menu(flx):
|
||||||
|
sys.argv = ["falyx", "T"]
|
||||||
|
flx.default_to_menu = False
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
await flx.run()
|
||||||
|
|
||||||
|
await flx.run(always_start_menu=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_default_to_menu_help(flx):
|
||||||
|
sys.argv = ["falyx"]
|
||||||
|
flx.default_to_menu = False
|
||||||
|
with pytest.raises(SystemExit, match="0"):
|
||||||
|
with falyx_console.capture() as capture:
|
||||||
|
await flx.run()
|
||||||
|
|
||||||
|
captured = Text.from_ansi(capture.get()).plain
|
||||||
|
assert "Show this help menu." in captured
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_debug_hooks(flx):
|
||||||
|
sys.argv = ["falyx", "--debug-hooks", "T"]
|
||||||
|
|
||||||
|
assert flx.options.get("debug_hooks") is False
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
await flx.run()
|
||||||
|
|
||||||
|
assert flx.options.get("debug_hooks") is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_never_prompt(flx):
|
||||||
|
sys.argv = ["falyx", "--never-prompt", "T"]
|
||||||
|
|
||||||
|
assert flx.options.get("never_prompt") is False
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
await flx.run()
|
||||||
|
|
||||||
|
falyx_console.print(flx.options.get_namespace_dict("default"))
|
||||||
|
|
||||||
|
assert flx.options.get("debug_hooks") is False
|
||||||
|
assert flx.options.get("never_prompt") is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_bad_args(flx):
|
||||||
|
sys.argv = ["falyx", "T", "--unknown-arg"]
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit, match="2"):
|
||||||
|
await flx.run()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_help(flx):
|
||||||
|
sys.argv = ["falyx", "T", "--help"]
|
||||||
|
with pytest.raises(SystemExit, match="0"):
|
||||||
|
await flx.run()
|
||||||
|
|
||||||
|
sys.argv = ["falyx", "--help"]
|
||||||
|
with pytest.raises(SystemExit, match="0"):
|
||||||
|
await flx.run()
|
||||||
|
|
||||||
|
sys.argv = ["falyx", "-h"]
|
||||||
|
with pytest.raises(SystemExit, match="0"):
|
||||||
|
await flx.run()
|
||||||
|
|
||||||
|
sys.argv = ["falyx", "--tldr"]
|
||||||
|
with pytest.raises(SystemExit, match="0"):
|
||||||
|
await flx.run()
|
||||||
|
|
||||||
|
sys.argv = ["falyx", "-T"]
|
||||||
|
with pytest.raises(SystemExit, match="0"):
|
||||||
|
await flx.run()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_entry_not_found(flx):
|
||||||
|
sys.argv = ["falyx", "UNKNOWN_COMMAND"]
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit, match="2"):
|
||||||
|
await flx.run()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_test_exceptions(flx):
|
||||||
|
flx.add_command(
|
||||||
|
"E",
|
||||||
|
"Throw Error",
|
||||||
|
action=throw_error_action,
|
||||||
|
)
|
||||||
|
|
||||||
|
sys.argv = ["falyx", "E", "ValueError"]
|
||||||
|
with pytest.raises(SystemExit, match="1"):
|
||||||
|
await flx.run()
|
||||||
|
|
||||||
|
sys.argv = ["falyx", "E", "QuitSignal"]
|
||||||
|
with pytest.raises(SystemExit, match="130"):
|
||||||
|
await flx.run()
|
||||||
|
|
||||||
|
sys.argv = ["falyx", "E", "BackSignal"]
|
||||||
|
with pytest.raises(SystemExit, match="1"):
|
||||||
|
await flx.run()
|
||||||
|
|
||||||
|
sys.argv = ["falyx", "E", "CancelSignal"]
|
||||||
|
with pytest.raises(SystemExit, match="1"):
|
||||||
|
await flx.run()
|
||||||
|
|
||||||
|
sys.argv = ["falyx", "E", "HelpSignal"]
|
||||||
|
with pytest.raises(SystemExit, match="1"):
|
||||||
|
await flx.run()
|
||||||
|
|
||||||
|
sys.argv = ["falyx", "E", "FlowSignal"]
|
||||||
|
with pytest.raises(SystemExit, match="1"):
|
||||||
|
await flx.run()
|
||||||
|
|
||||||
|
sys.argv = ["falyx", "--verbose", "E", "FalyxError"]
|
||||||
|
with pytest.raises(SystemExit, match="1"):
|
||||||
|
await flx.run()
|
||||||
|
|
||||||
|
sys.argv = ["falyx", "E", "UnknownError"]
|
||||||
|
with pytest.raises(SystemExit, match="1"):
|
||||||
|
await flx.run()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_no_args(flx):
|
||||||
|
sys.argv = ["falyx"]
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit, match="0"):
|
||||||
|
await flx.run()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_submenu(flx_with_submenu):
|
||||||
|
sys.argv = ["falyx", "S", "T"]
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit, match="0"):
|
||||||
|
await flx_with_submenu.run()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_submenu_help(flx_with_submenu):
|
||||||
|
sys.argv = ["falyx", "S", "--help"]
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit, match="0"):
|
||||||
|
await flx_with_submenu.run()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_submenu_tldr(flx_with_submenu):
|
||||||
|
sys.argv = ["falyx", "S", "--tldr"]
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit, match="0"):
|
||||||
|
await flx_with_submenu.run()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_preview(flx):
|
||||||
|
sys.argv = ["falyx", "preview", "T"]
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit, match="0"):
|
||||||
|
with falyx_console.capture() as capture:
|
||||||
|
await flx.run()
|
||||||
|
|
||||||
|
captured = Text.from_ansi(capture.get()).plain
|
||||||
|
assert "Command: 'T'" in captured
|
||||||
|
assert "Would call: <lambda>(args=(), kwargs={})" in captured
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
from argparse import ArgumentParser, Namespace, _SubParsersAction
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from falyx.__main__ import (
|
from falyx.__main__ import bootstrap, find_falyx_config, init_config, main
|
||||||
bootstrap,
|
|
||||||
find_falyx_config,
|
|
||||||
get_parsers,
|
|
||||||
init_callback,
|
|
||||||
init_config,
|
|
||||||
main,
|
|
||||||
)
|
|
||||||
from falyx.parser import CommandArgumentParser
|
from falyx.parser import CommandArgumentParser
|
||||||
|
|
||||||
|
|
||||||
@@ -94,38 +86,10 @@ async def test_init_config():
|
|||||||
assert args["name"] == "."
|
assert args["name"] == "."
|
||||||
|
|
||||||
|
|
||||||
def test_init_callback(tmp_path):
|
|
||||||
"""Test if the init_callback function works correctly."""
|
|
||||||
# Test project initialization
|
|
||||||
args = Namespace(command="init", name=str(tmp_path))
|
|
||||||
init_callback(args)
|
|
||||||
assert (tmp_path / "falyx.yaml").exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_init_global_callback():
|
|
||||||
# Test global initialization
|
|
||||||
args = Namespace(command="init_global")
|
|
||||||
init_callback(args)
|
|
||||||
assert (Path.home() / ".config" / "falyx" / "tasks.py").exists()
|
|
||||||
assert (Path.home() / ".config" / "falyx" / "falyx.yaml").exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_parsers():
|
|
||||||
"""Test if the get_parsers function returns the correct parsers."""
|
|
||||||
root_parser, subparsers = get_parsers()
|
|
||||||
assert isinstance(root_parser, ArgumentParser)
|
|
||||||
assert isinstance(subparsers, _SubParsersAction)
|
|
||||||
|
|
||||||
# Check if the 'init' command is available
|
|
||||||
init_parser = subparsers.choices.get("init")
|
|
||||||
assert init_parser is not None
|
|
||||||
assert "name" == init_parser._get_positional_actions()[0].dest
|
|
||||||
|
|
||||||
|
|
||||||
def test_main():
|
def test_main():
|
||||||
"""Test if the main function runs with the correct arguments."""
|
"""Test if the main function runs with the correct arguments."""
|
||||||
|
|
||||||
sys.argv = ["falyx", "run", "?"]
|
sys.argv = ["falyx", "?"]
|
||||||
|
|
||||||
with pytest.raises(SystemExit) as exc_info:
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -71,22 +71,28 @@ async def test_action_with_nargs_positional():
|
|||||||
return int(a) * int(b)
|
return int(a) * int(b)
|
||||||
|
|
||||||
action = Action("multiply", multiply)
|
action = Action("multiply", multiply)
|
||||||
parser.add_argument("mul", action=ArgumentAction.ACTION, resolver=action, nargs=2)
|
parser.add_argument(
|
||||||
|
"mul",
|
||||||
|
action=ArgumentAction.ACTION,
|
||||||
|
resolver=action,
|
||||||
|
nargs=2,
|
||||||
|
type=int,
|
||||||
|
)
|
||||||
args = await parser.parse_args(["3", "4"])
|
args = await parser.parse_args(["3", "4"])
|
||||||
assert args["mul"] == 12
|
assert args["mul"] == 12
|
||||||
|
|
||||||
with pytest.raises(CommandArgumentError):
|
with pytest.raises(CommandArgumentError):
|
||||||
await parser.parse_args(["3"])
|
await parser.parse_args(["3"])
|
||||||
|
|
||||||
with pytest.raises(CommandArgumentError):
|
|
||||||
await parser.parse_args([])
|
|
||||||
|
|
||||||
with pytest.raises(CommandArgumentError):
|
with pytest.raises(CommandArgumentError):
|
||||||
await parser.parse_args(["3", "4", "5"])
|
await parser.parse_args(["3", "4", "5"])
|
||||||
|
|
||||||
with pytest.raises(CommandArgumentError):
|
with pytest.raises(CommandArgumentError):
|
||||||
await parser.parse_args(["--mul", "3", "4"])
|
await parser.parse_args(["--mul", "3", "4"])
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
await parser.parse_args([])
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_action_with_nargs_positional_int():
|
async def test_action_with_nargs_positional_int():
|
||||||
@@ -102,6 +108,9 @@ async def test_action_with_nargs_positional_int():
|
|||||||
args = await parser.parse_args(["3", "4"])
|
args = await parser.parse_args(["3", "4"])
|
||||||
assert args["mul"] == 12
|
assert args["mul"] == 12
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
await parser.parse_args([])
|
||||||
|
|
||||||
with pytest.raises(CommandArgumentError):
|
with pytest.raises(CommandArgumentError):
|
||||||
await parser.parse_args(["3"])
|
await parser.parse_args(["3"])
|
||||||
|
|
||||||
@@ -209,11 +218,19 @@ async def test_action_with_default_and_value_not():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_action_with_default_and_value_positional():
|
async def test_action_with_default_and_value_positional():
|
||||||
parser = CommandArgumentParser()
|
parser = CommandArgumentParser()
|
||||||
action = Action("default", lambda: "default_value")
|
action = Action("action", lambda x: x)
|
||||||
parser.add_argument("default", action=ArgumentAction.ACTION, resolver=action)
|
parser.add_argument(
|
||||||
|
"default",
|
||||||
|
action=ArgumentAction.ACTION,
|
||||||
|
resolver=action,
|
||||||
|
default="default_value",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = await parser.parse_args([])
|
||||||
|
assert args["default"] == "default_value"
|
||||||
|
|
||||||
|
args = await parser.parse_args(["be"])
|
||||||
|
assert args["default"] == "be"
|
||||||
|
|
||||||
with pytest.raises(CommandArgumentError):
|
with pytest.raises(CommandArgumentError):
|
||||||
await parser.parse_args([])
|
await parser.parse_args(["one", "new_value"])
|
||||||
|
|
||||||
with pytest.raises(CommandArgumentError):
|
|
||||||
await parser.parse_args(["be"])
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
from falyx.exceptions import CommandArgumentError
|
from falyx.action import Action
|
||||||
|
from falyx.console import console as falyx_console
|
||||||
|
from falyx.exceptions import CommandArgumentError, NotAFalyxError
|
||||||
|
from falyx.options_manager import OptionsManager
|
||||||
from falyx.parser import ArgumentAction, CommandArgumentParser
|
from falyx.parser import ArgumentAction, CommandArgumentParser
|
||||||
from falyx.signals import HelpSignal
|
from falyx.signals import HelpSignal
|
||||||
|
|
||||||
@@ -431,7 +435,6 @@ async def test_parse_args_flagged_nargs_plus():
|
|||||||
assert args["files"] == ["a", "b", "c"]
|
assert args["files"] == ["a", "b", "c"]
|
||||||
|
|
||||||
args = await parser.parse_args(["--files", "a"])
|
args = await parser.parse_args(["--files", "a"])
|
||||||
print(args)
|
|
||||||
assert args["files"] == ["a"]
|
assert args["files"] == ["a"]
|
||||||
|
|
||||||
args = await parser.parse_args([])
|
args = await parser.parse_args([])
|
||||||
@@ -666,7 +669,7 @@ async def test_parse_args_split_order():
|
|||||||
cap.add_argument("a")
|
cap.add_argument("a")
|
||||||
cap.add_argument("--x")
|
cap.add_argument("--x")
|
||||||
cap.add_argument("b", nargs="*")
|
cap.add_argument("b", nargs="*")
|
||||||
args, kwargs = await cap.parse_args_split(["1", "--x", "100", "2"])
|
args, kwargs, _ = await cap.parse_args_split(["1", "--x", "100", "2"])
|
||||||
assert args == ("1", ["2"])
|
assert args == ("1", ["2"])
|
||||||
assert kwargs == {"x": "100"}
|
assert kwargs == {"x": "100"}
|
||||||
|
|
||||||
@@ -826,4 +829,183 @@ async def test_render_help():
|
|||||||
parser.add_argument("--foo", type=str, help="Foo help")
|
parser.add_argument("--foo", type=str, help="Foo help")
|
||||||
parser.add_argument("--bar", action=ArgumentAction.APPEND, type=str, help="Bar help")
|
parser.add_argument("--bar", action=ArgumentAction.APPEND, type=str, help="Bar help")
|
||||||
|
|
||||||
assert parser.render_help() is None
|
with falyx_console.capture() as capture:
|
||||||
|
parser.render_help()
|
||||||
|
output = Text.from_ansi(capture.get()).plain
|
||||||
|
assert "usage:" in output
|
||||||
|
assert "--foo" in output
|
||||||
|
assert "Foo help" in output
|
||||||
|
assert "--bar" in output
|
||||||
|
assert "Bar help" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_argument_parser_set_options_manager_invalid():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
|
||||||
|
with pytest.raises(NotAFalyxError):
|
||||||
|
parser.set_options_manager("not_a_options_manager")
|
||||||
|
|
||||||
|
with pytest.raises(NotAFalyxError):
|
||||||
|
parser.set_options_manager(123)
|
||||||
|
|
||||||
|
with pytest.raises(NotAFalyxError):
|
||||||
|
parser.set_options_manager(None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_argument_parser_set_options_manager_valid():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
options_manager = OptionsManager([("new_namespace", {"foo": "bar"})])
|
||||||
|
parser.set_options_manager(options_manager)
|
||||||
|
assert parser.options_manager == options_manager
|
||||||
|
assert parser.options_manager.get("foo", namespace_name="new_namespace") == "bar"
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_argument_invalid_required():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
parser.add_argument("--foo", action=ArgumentAction.STORE_TRUE, required=True)
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
parser.add_argument("--foo", action=ArgumentAction.STORE_FALSE, required=True)
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
parser.add_argument(
|
||||||
|
"--foo", action=ArgumentAction.STORE_BOOL_OPTIONAL, required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_argument_invalid_choices():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
parser.add_argument("--foo", action="store_true", choices="not_a_list")
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
parser.add_argument("--foo", choices=123)
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
parser.add_argument("--foo", choices={"a": 1, "b": 2})
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
parser.add_argument("--foo", choices=["a", "b"], type=int)
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_argument_resolver_invalid():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
parser.add_argument("--foo", resolver=lambda x: x)
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
parser.add_argument("--foo", resolver=123)
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
parser.add_argument("--foo", action="action", resolver="not_a_function")
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_argument_resolver_valid():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--foo", action="action", resolver=Action("test", lambda x: x.upper())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_argument_resolve_invalid_default():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
parser.add_argument("--foo", action="store_true", default="any value")
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
parser.add_argument("--foo", action="store_false", default=False)
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
parser.add_argument("--foo", action="store_true", default=True)
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
parser.add_argument("--foo", action="store_bool_optional", default=False)
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
parser.add_argument("--foo", action="count", default=500)
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
parser.add_argument("--foo", action="append", default="not a list")
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
parser.add_argument("--foo", action="extend", default="not a list")
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
parser.add_argument("--count", action="count", default=0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_argument_resolve_valid_default():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
|
||||||
|
parser.add_argument("--foo", action="store_true", default=False)
|
||||||
|
|
||||||
|
parser.add_argument("--bar", action="store_false", default=True)
|
||||||
|
|
||||||
|
parser.add_argument("--baz", action="store_bool_optional", default=None)
|
||||||
|
|
||||||
|
parser.add_argument("--items", action="append", default=[])
|
||||||
|
|
||||||
|
parser.add_argument("--values", action="extend", default=[])
|
||||||
|
|
||||||
|
parser.add_argument("--number", action="store", nargs=1, type=int, default=0)
|
||||||
|
|
||||||
|
result = await parser.parse_args(["--number", "5"])
|
||||||
|
|
||||||
|
assert result["foo"] is False
|
||||||
|
assert result["bar"] is True
|
||||||
|
assert result["baz"] is None
|
||||||
|
assert result["items"] == []
|
||||||
|
assert result["values"] == []
|
||||||
|
assert result["number"] == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_argument_in_reserved_dests():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
CommandArgumentError,
|
||||||
|
match="invalid dest .*'help' is reserved and cannot be used.",
|
||||||
|
):
|
||||||
|
parser.add_argument("--help")
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
CommandArgumentError,
|
||||||
|
match="invalid dest .*'tldr' is reserved and cannot be used.",
|
||||||
|
):
|
||||||
|
parser.add_argument("--tldr")
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_argument_in_reserved_dests_positional():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
CommandArgumentError,
|
||||||
|
match="invalid dest .*'help' is reserved and cannot be used.",
|
||||||
|
):
|
||||||
|
parser.add_argument("help")
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
CommandArgumentError,
|
||||||
|
match="invalid dest .*'tldr' is reserved and cannot be used.",
|
||||||
|
):
|
||||||
|
parser.add_argument("tldr")
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_argument_invalid_suggestions():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
CommandArgumentError, match="suggestions must be a list or None, got int"
|
||||||
|
):
|
||||||
|
parser.add_argument("--valid", suggestions=112445)
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_argument_invalid_lazy_resolver():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
CommandArgumentError, match="lazy_resolver must be a boolean, got int"
|
||||||
|
):
|
||||||
|
parser.add_argument("--valid", lazy_resolver=123)
|
||||||
158
tests/test_parsers/test_execution_option_registration.py
Normal file
158
tests/test_parsers/test_execution_option_registration.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from falyx.exceptions import CommandArgumentError
|
||||||
|
from falyx.execution_option import ExecutionOption
|
||||||
|
from falyx.parser import CommandArgumentParser
|
||||||
|
|
||||||
|
|
||||||
|
def test_enable_execution_options_registers_summary_flag():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
|
||||||
|
assert "--summary" in parser._flag_map
|
||||||
|
assert "--summary" in parser._keyword
|
||||||
|
assert "--summary" in parser._flag_map
|
||||||
|
assert "summary" in parser._execution_dests
|
||||||
|
|
||||||
|
|
||||||
|
def test_enable_execution_options_registers_retry_flags():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
parser.enable_execution_options(frozenset({ExecutionOption.RETRY}))
|
||||||
|
assert "--retries" in parser._flag_map
|
||||||
|
assert "--retries" in parser._keyword
|
||||||
|
assert "--retries" in parser._flag_map
|
||||||
|
assert "retries" in parser._execution_dests
|
||||||
|
assert "--retry-delay" in parser._flag_map
|
||||||
|
assert "--retry-delay" in parser._keyword
|
||||||
|
assert "--retry-delay" in parser._flag_map
|
||||||
|
assert "retry_delay" in parser._execution_dests
|
||||||
|
assert "--retry-backoff" in parser._flag_map
|
||||||
|
assert "--retry-backoff" in parser._keyword
|
||||||
|
assert "--retry-backoff" in parser._flag_map
|
||||||
|
assert "retry_backoff" in parser._execution_dests
|
||||||
|
|
||||||
|
|
||||||
|
def test_enable_execution_options_invalid_double_registration_raises():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
|
||||||
|
with pytest.raises(
|
||||||
|
CommandArgumentError, match="destination 'summary' is already defined"
|
||||||
|
):
|
||||||
|
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
CommandArgumentError,
|
||||||
|
match="destination 'summary' is already registered as an execution argument",
|
||||||
|
):
|
||||||
|
parser._register_execution_dest("summary")
|
||||||
|
|
||||||
|
|
||||||
|
def test_enable_execution_options_registers_confirm_flags():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
parser.enable_execution_options(frozenset({ExecutionOption.CONFIRM}))
|
||||||
|
assert "--confirm" in parser._flag_map
|
||||||
|
assert "--confirm" in parser._keyword
|
||||||
|
assert "--confirm" in parser._flag_map
|
||||||
|
assert "force_confirm" in parser._execution_dests
|
||||||
|
assert "--skip-confirm" in parser._flag_map
|
||||||
|
assert "--skip-confirm" in parser._keyword
|
||||||
|
assert "--skip-confirm" in parser._flag_map
|
||||||
|
assert "skip_confirm" in parser._execution_dests
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_execution_dest_rejects_duplicates():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
|
||||||
|
with pytest.raises(
|
||||||
|
CommandArgumentError, match="destination 'summary' is already defined"
|
||||||
|
):
|
||||||
|
parser.add_argument("--summary", action="store_true")
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
CommandArgumentError, match="destination 'summary' is already defined"
|
||||||
|
):
|
||||||
|
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_args_split_with_execution_options_returns_correct_execution_args():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
parser.add_argument("foo", type=int, help="A business argument.")
|
||||||
|
parser.add_argument("--bar", type=int, help="A business argument.")
|
||||||
|
parser.enable_execution_options(
|
||||||
|
frozenset({ExecutionOption.SUMMARY, ExecutionOption.RETRY})
|
||||||
|
)
|
||||||
|
|
||||||
|
args, kwargs, execution_args = await parser.parse_args_split(
|
||||||
|
["50", "--bar", "42", "--summary", "--retries", "3"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert args == (50,)
|
||||||
|
assert kwargs == {"bar": 42}
|
||||||
|
assert execution_args == {
|
||||||
|
"summary": True,
|
||||||
|
"retries": 3,
|
||||||
|
"retry_delay": 0.0,
|
||||||
|
"retry_backoff": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_args_split_with_all_execution_options_returns_correct_execution_args():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
parser.add_argument("foo", type=int, help="A business argument.")
|
||||||
|
parser.add_argument("--bar", type=int, help="A business argument.")
|
||||||
|
parser.enable_execution_options(
|
||||||
|
frozenset(
|
||||||
|
{
|
||||||
|
ExecutionOption.SUMMARY,
|
||||||
|
ExecutionOption.RETRY,
|
||||||
|
ExecutionOption.CONFIRM,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
args, kwargs, execution_args = await parser.parse_args_split(
|
||||||
|
[
|
||||||
|
"50",
|
||||||
|
"--bar",
|
||||||
|
"42",
|
||||||
|
"--summary",
|
||||||
|
"--retries",
|
||||||
|
"3",
|
||||||
|
"--confirm",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert args == (50,)
|
||||||
|
assert kwargs == {"bar": 42}
|
||||||
|
assert execution_args == {
|
||||||
|
"summary": True,
|
||||||
|
"retries": 3,
|
||||||
|
"retry_delay": 0.0,
|
||||||
|
"retry_backoff": 0.0,
|
||||||
|
"force_confirm": True,
|
||||||
|
"skip_confirm": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_args_split_with_no_execution_options_returns_empty_execution_args():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
parser.add_argument("foo", type=int, help="A business argument.")
|
||||||
|
parser.add_argument("--bar", type=int, help="A business argument.")
|
||||||
|
|
||||||
|
args, kwargs, execution_args = await parser.parse_args_split(["50", "--bar", "42"])
|
||||||
|
|
||||||
|
assert args == (50,)
|
||||||
|
assert kwargs == {"bar": 42}
|
||||||
|
assert execution_args == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_args_split_with_conflicting_execution_option_raises():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
parser.add_argument("--summary", action="store_true", help="A conflicting argument.")
|
||||||
|
with pytest.raises(
|
||||||
|
CommandArgumentError, match="destination 'summary' is already defined"
|
||||||
|
):
|
||||||
|
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
|
||||||
96
tests/test_parsers/test_group_builder.py
Normal file
96
tests/test_parsers/test_group_builder.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from falyx.exceptions import CommandArgumentError
|
||||||
|
from falyx.parser import CommandArgumentParser
|
||||||
|
from falyx.parser.command_argument_parser import _GroupBuilder
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_builder():
|
||||||
|
parser = CommandArgumentParser(program="test_program")
|
||||||
|
group_builder = _GroupBuilder(parser, group_name="test_group")
|
||||||
|
assert group_builder.group_name == "test_group"
|
||||||
|
assert "group='test_group'" in str(group_builder)
|
||||||
|
|
||||||
|
group_builder = _GroupBuilder(
|
||||||
|
parser,
|
||||||
|
mutex_name="test_group",
|
||||||
|
)
|
||||||
|
assert group_builder.mutex_name == "test_group"
|
||||||
|
assert "mutex_group='test_group'" in str(group_builder)
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
_GroupBuilder(parser, group_name="test_group", mutex_name="test_group")
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
_GroupBuilder(parser)
|
||||||
|
|
||||||
|
with pytest.raises(AssertionError):
|
||||||
|
builder = _GroupBuilder(parser, group_name="test_group")
|
||||||
|
builder.group_name = None
|
||||||
|
builder.mutex_name = None
|
||||||
|
str(builder)
|
||||||
|
|
||||||
|
|
||||||
|
def test_adding_arguments_to_group():
|
||||||
|
parser = CommandArgumentParser(program="test_program")
|
||||||
|
|
||||||
|
group = parser.add_argument_group("test_group")
|
||||||
|
assert group.group_name == "test_group"
|
||||||
|
|
||||||
|
group.add_argument("--foo", type=str, help="Foo argument")
|
||||||
|
group.add_argument("--bar", type=int, help="Bar argument")
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
parser.add_argument_group("test_group")
|
||||||
|
|
||||||
|
|
||||||
|
def test_adding_arguments_to_mutex_group():
|
||||||
|
parser = CommandArgumentParser(program="test_program")
|
||||||
|
|
||||||
|
mutex_group = parser.add_mutually_exclusive_group("test_mutex_group")
|
||||||
|
assert mutex_group.mutex_name == "test_mutex_group"
|
||||||
|
|
||||||
|
mutex_group.add_argument("--foo", type=str, help="Foo argument")
|
||||||
|
mutex_group.add_argument("--bar", type=int, help="Bar argument")
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
parser.add_mutually_exclusive_group("test_mutex_group")
|
||||||
|
|
||||||
|
|
||||||
|
def test_adding_arguments_to_group_with_invalid_group():
|
||||||
|
parser = CommandArgumentParser(program="test_program")
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
parser.add_argument(
|
||||||
|
"--foo", type=str, help="Foo argument", group="non_existent_group"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
parser.add_argument(
|
||||||
|
"--bar", type=int, help="Bar argument", mutex_group="non_existent_group"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_adding_positional_arguments_to_mutex_group():
|
||||||
|
parser = CommandArgumentParser(program="test_program")
|
||||||
|
|
||||||
|
group = parser.add_mutually_exclusive_group("test_group")
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
group.add_argument(
|
||||||
|
"positional_arg", type=str, help="This should fail because it's positional"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_adding_required_arguments_to_mutex_group():
|
||||||
|
parser = CommandArgumentParser(program="test_program")
|
||||||
|
|
||||||
|
group = parser.add_mutually_exclusive_group("test_group")
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
group.add_argument(
|
||||||
|
"--foo",
|
||||||
|
type=str,
|
||||||
|
help="This should fail because it's required",
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
241
tests/test_parsers/test_resolve_args.py
Normal file
241
tests/test_parsers/test_resolve_args.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from falyx.command import Command
|
||||||
|
from falyx.exceptions import CommandArgumentError, NotAFalyxError
|
||||||
|
from falyx.execution_option import ExecutionOption
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_args_separates_business_and_execution_options():
|
||||||
|
command = Command.build(
|
||||||
|
key="T",
|
||||||
|
description="Test Command",
|
||||||
|
action=lambda: None,
|
||||||
|
execution_options=["summary", "retry"],
|
||||||
|
)
|
||||||
|
command.arg_parser.add_argument("--foo", type=int, help="A business argument.")
|
||||||
|
|
||||||
|
args, kwargs, execution_args = await command.resolve_args(
|
||||||
|
["--foo", "42", "--summary", "--retries", "3"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert args == ()
|
||||||
|
assert kwargs == {"foo": 42}
|
||||||
|
assert execution_args == {
|
||||||
|
"summary": True,
|
||||||
|
"retries": 3,
|
||||||
|
"retry_delay": 0.0,
|
||||||
|
"retry_backoff": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
args, kwargs, execution_args = await command.arg_parser.parse_args_split(
|
||||||
|
["--foo", "42", "--summary", "--retries", "3"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert args == ()
|
||||||
|
assert kwargs == {"foo": 42}
|
||||||
|
assert execution_args == {
|
||||||
|
"summary": True,
|
||||||
|
"retries": 3,
|
||||||
|
"retry_delay": 0.0,
|
||||||
|
"retry_backoff": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_args_split_with_no_execution_options_returns_empty_execution_args():
|
||||||
|
command = Command.build(
|
||||||
|
key="T",
|
||||||
|
description="Test Command",
|
||||||
|
action=lambda: None,
|
||||||
|
)
|
||||||
|
command.arg_parser.add_argument("--foo", type=int, help="A business argument.")
|
||||||
|
|
||||||
|
args, kwargs, execution_args = await command.arg_parser.parse_args_split(
|
||||||
|
["--foo", "42"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert args == ()
|
||||||
|
assert kwargs == {"foo": 42}
|
||||||
|
assert execution_args == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_args_raises_on_conflicting_execution_option():
|
||||||
|
command = Command.build(
|
||||||
|
key="T",
|
||||||
|
description="Test Command",
|
||||||
|
action=lambda: None,
|
||||||
|
execution_options=["summary"],
|
||||||
|
)
|
||||||
|
with pytest.raises(
|
||||||
|
CommandArgumentError, match="destination 'summary' is already defined"
|
||||||
|
):
|
||||||
|
command.arg_parser.add_argument(
|
||||||
|
"--summary", action="store_true", help="A conflicting argument."
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
CommandArgumentError, match="destination 'summary' is already defined"
|
||||||
|
):
|
||||||
|
command.arg_parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_args_mix_of_business_and_execution_options():
|
||||||
|
command = Command.build(
|
||||||
|
key="T",
|
||||||
|
description="Test Command",
|
||||||
|
action=lambda: None,
|
||||||
|
execution_options=["retry"],
|
||||||
|
)
|
||||||
|
command.arg_parser.add_argument("--summary", type=str, help="A business argument.")
|
||||||
|
|
||||||
|
args, kwargs, execution_args = await command.resolve_args(
|
||||||
|
["--summary", "test", "--retries", "5", "--retry-delay", "2"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert args == ()
|
||||||
|
assert kwargs == {"summary": "test"}
|
||||||
|
assert execution_args == {"retries": 5, "retry_delay": 2.0, "retry_backoff": 0.0}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_args_with_no_arguments():
|
||||||
|
command = Command.build(
|
||||||
|
key="T",
|
||||||
|
description="Test Command",
|
||||||
|
action=lambda: None,
|
||||||
|
execution_options=["summary"],
|
||||||
|
)
|
||||||
|
|
||||||
|
args, kwargs, execution_args = await command.resolve_args([])
|
||||||
|
|
||||||
|
assert args == ()
|
||||||
|
assert kwargs == {}
|
||||||
|
assert execution_args == {"summary": False}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_args_with_confirmation_options():
|
||||||
|
command = Command.build(
|
||||||
|
key="T",
|
||||||
|
description="Test Command",
|
||||||
|
action=lambda: None,
|
||||||
|
execution_options=["confirm"],
|
||||||
|
)
|
||||||
|
|
||||||
|
args, kwargs, execution_args = await command.resolve_args(["--confirm"])
|
||||||
|
|
||||||
|
assert args == ()
|
||||||
|
assert kwargs == {}
|
||||||
|
assert execution_args == {"force_confirm": True, "skip_confirm": False}
|
||||||
|
|
||||||
|
args, kwargs, execution_args = await command.resolve_args(["--skip-confirm"])
|
||||||
|
|
||||||
|
assert args == ()
|
||||||
|
assert kwargs == {}
|
||||||
|
assert execution_args == {"force_confirm": False, "skip_confirm": True}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_args_with_all_execution_options():
|
||||||
|
command = Command.build(
|
||||||
|
key="T",
|
||||||
|
description="Test Command",
|
||||||
|
action=lambda: None,
|
||||||
|
execution_options=["summary", "retry", "confirm"],
|
||||||
|
)
|
||||||
|
|
||||||
|
args, kwargs, execution_args = await command.resolve_args(
|
||||||
|
["--summary", "--retries", "3", "--confirm"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert args == ()
|
||||||
|
assert kwargs == {}
|
||||||
|
assert execution_args == {
|
||||||
|
"summary": True,
|
||||||
|
"retries": 3,
|
||||||
|
"retry_delay": 0.0,
|
||||||
|
"retry_backoff": 0.0,
|
||||||
|
"force_confirm": True,
|
||||||
|
"skip_confirm": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_args_with_raw_string_input():
|
||||||
|
command = Command.build(
|
||||||
|
key="T",
|
||||||
|
description="Test Command",
|
||||||
|
action=lambda: None,
|
||||||
|
execution_options=["summary"],
|
||||||
|
)
|
||||||
|
command.arg_parser.add_argument("--foo", type=int, help="A business argument.")
|
||||||
|
|
||||||
|
args, kwargs, execution_args = await command.resolve_args("--foo 42 --summary")
|
||||||
|
|
||||||
|
assert args == ()
|
||||||
|
assert kwargs == {"foo": 42}
|
||||||
|
assert execution_args == {"summary": True}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_args_with_no_arg_parser():
|
||||||
|
command = Command.build(
|
||||||
|
key="T",
|
||||||
|
description="Test Command",
|
||||||
|
action=lambda: None,
|
||||||
|
execution_options=["summary"],
|
||||||
|
)
|
||||||
|
command.arg_parser = None
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
NotAFalyxError,
|
||||||
|
match="Command has no parser configured. Provide a custom_parser or CommandArgumentParser.",
|
||||||
|
):
|
||||||
|
await command.resolve_args("--summary")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_args_with_custom_parser():
|
||||||
|
def parse_args_split(arg_list):
|
||||||
|
return (arg_list,), {}, {"custom_execution_arg": True}
|
||||||
|
|
||||||
|
command = Command.build(
|
||||||
|
key="T",
|
||||||
|
description="Test Command",
|
||||||
|
action=lambda: None,
|
||||||
|
execution_options=["summary"],
|
||||||
|
)
|
||||||
|
command.custom_parser = parse_args_split
|
||||||
|
|
||||||
|
args, kwargs, execution_args = await command.resolve_args("--summary")
|
||||||
|
|
||||||
|
assert args == (["--summary"],)
|
||||||
|
assert kwargs == {}
|
||||||
|
assert execution_args == {"custom_execution_arg": True}
|
||||||
|
|
||||||
|
# TODO: is this the right behavior? Should we expect the custom parser to handle non string inputs as well? Does this actually happen?
|
||||||
|
args, kwargs, execution_args = await command.resolve_args(2235235)
|
||||||
|
|
||||||
|
assert args == (2235235,)
|
||||||
|
assert kwargs == {}
|
||||||
|
assert execution_args == {"custom_execution_arg": True}
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError, match="Failed to parse arguments:"):
|
||||||
|
args, kwargs, execution_args = await command.resolve_args("unbalanced 'quotes")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_args_str_unbalanced_quotes():
|
||||||
|
command = Command.build(
|
||||||
|
key="T",
|
||||||
|
description="Test Command",
|
||||||
|
action=lambda: None,
|
||||||
|
execution_options=["summary"],
|
||||||
|
)
|
||||||
|
command.arg_parser.add_argument("--foo", type=str, help="A business argument.")
|
||||||
|
|
||||||
|
with pytest.raises(CommandArgumentError, match="Failed to parse arguments:"):
|
||||||
|
await command.resolve_args("--foo 'unbalanced quotes")
|
||||||
@@ -2,6 +2,7 @@ import pytest
|
|||||||
|
|
||||||
from falyx.exceptions import CommandArgumentError
|
from falyx.exceptions import CommandArgumentError
|
||||||
from falyx.parser.command_argument_parser import CommandArgumentParser
|
from falyx.parser.command_argument_parser import CommandArgumentParser
|
||||||
|
from falyx.parser.parser_types import TLDRExample
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -45,3 +46,27 @@ async def test_add_tldr_examples_in_init():
|
|||||||
assert parser._tldr_examples[0].description == "This is the first example."
|
assert parser._tldr_examples[0].description == "This is the first example."
|
||||||
assert parser._tldr_examples[1].usage == "example2"
|
assert parser._tldr_examples[1].usage == "example2"
|
||||||
assert parser._tldr_examples[1].description == "This is the second example."
|
assert parser._tldr_examples[1].description == "This is the second example."
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_tldr_example():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
parser.add_tldr_example("example1", "This is the first example.")
|
||||||
|
assert len(parser._tldr_examples) == 1
|
||||||
|
assert parser._tldr_examples[0].usage == "example1"
|
||||||
|
assert parser._tldr_examples[0].description == "This is the first example."
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_tldr_example_bad_args():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
parser.add_tldr_example("example1", "This is the first example.", "extra_arg")
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_tldr_examples_with_tldr_example_objects():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
example1 = TLDRExample(usage="example1", description="This is the first example.")
|
||||||
|
example2 = TLDRExample(usage="example2", description="This is the second example.")
|
||||||
|
parser.add_tldr_examples([example1, example2])
|
||||||
|
assert len(parser._tldr_examples) == 2
|
||||||
|
assert parser._tldr_examples[0] == example1
|
||||||
|
assert parser._tldr_examples[1] == example2
|
||||||
|
|||||||
541
tests/test_runner/test_command_runner.py
Normal file
541
tests/test_runner/test_command_runner.py
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
|
from falyx.action import Action
|
||||||
|
from falyx.command import Command
|
||||||
|
from falyx.command_runner import CommandRunner
|
||||||
|
from falyx.console import console as falyx_console
|
||||||
|
from falyx.console import error_console
|
||||||
|
from falyx.exceptions import (
|
||||||
|
CommandArgumentError,
|
||||||
|
FalyxError,
|
||||||
|
InvalidHookError,
|
||||||
|
NotAFalyxError,
|
||||||
|
)
|
||||||
|
from falyx.hook_manager import HookManager, HookType
|
||||||
|
from falyx.options_manager import OptionsManager
|
||||||
|
from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
|
||||||
|
|
||||||
|
|
||||||
|
async def ok_action(*args, **kwargs):
|
||||||
|
falyx_console.print("Action executed with args:", args, "and kwargs:", kwargs)
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
|
async def failing_action(*args, **kwargs):
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
|
||||||
|
async def throw_error_action(error: str):
|
||||||
|
if error == "QuitSignal":
|
||||||
|
raise QuitSignal("Quit signal triggered.")
|
||||||
|
elif error == "BackSignal":
|
||||||
|
raise BackSignal("Back signal triggered.")
|
||||||
|
elif error == "CancelSignal":
|
||||||
|
raise CancelSignal("Cancel signal triggered.")
|
||||||
|
elif error == "ValueError":
|
||||||
|
raise ValueError("This is a ValueError.")
|
||||||
|
elif error == "HelpSignal":
|
||||||
|
raise HelpSignal("Help signal triggered.")
|
||||||
|
elif error == "FalyxError":
|
||||||
|
raise FalyxError("This is a FalyxError.")
|
||||||
|
else:
|
||||||
|
raise asyncio.CancelledError("An error occurred in the action.")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def command_throwing_error():
|
||||||
|
command = Command(
|
||||||
|
key="E",
|
||||||
|
description="Error Command",
|
||||||
|
action=Action("throw_error", throw_error_action),
|
||||||
|
execution_options=["retry"],
|
||||||
|
)
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def command_with_parser():
|
||||||
|
command = Command(
|
||||||
|
key="T",
|
||||||
|
description="Test Command",
|
||||||
|
action=ok_action,
|
||||||
|
)
|
||||||
|
command.arg_parser.add_argument("--foo", type=int, help="A business argument.")
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def command_with_no_parser():
|
||||||
|
command = Command(
|
||||||
|
key="T",
|
||||||
|
description="Test Command",
|
||||||
|
action=ok_action,
|
||||||
|
execution_options=["summary"],
|
||||||
|
)
|
||||||
|
command.arg_parser = None
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def command_with_custom_parser():
|
||||||
|
def parse_args_split(arg_list):
|
||||||
|
return (arg_list,), {}, {"custom_execution_arg": True}
|
||||||
|
|
||||||
|
command = Command(
|
||||||
|
key="T",
|
||||||
|
description="Test Command",
|
||||||
|
action=ok_action,
|
||||||
|
execution_options=["summary"],
|
||||||
|
)
|
||||||
|
command.custom_parser = parse_args_split
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def command_with_failing_action():
|
||||||
|
command = Command(
|
||||||
|
key="T",
|
||||||
|
description="Test Command",
|
||||||
|
action=failing_action,
|
||||||
|
execution_options=["summary", "retry"],
|
||||||
|
)
|
||||||
|
command.arg_parser.add_argument("--foo", type=int, help="A business argument.")
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def command_build_with_all_execution_options():
|
||||||
|
return Command.build(
|
||||||
|
key="T",
|
||||||
|
description="Test Command",
|
||||||
|
action=ok_action,
|
||||||
|
execution_options=["summary", "retry", "confirm"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def console():
|
||||||
|
return Console(record=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_command_runner_initialization(
|
||||||
|
command_with_parser,
|
||||||
|
command_with_no_parser,
|
||||||
|
command_with_custom_parser,
|
||||||
|
):
|
||||||
|
runner = CommandRunner(command_with_parser, program="test_program")
|
||||||
|
assert runner.command == command_with_parser
|
||||||
|
assert runner.program == "test_program"
|
||||||
|
assert runner.command.arg_parser.program == "test_program"
|
||||||
|
assert isinstance(runner.options, OptionsManager)
|
||||||
|
assert isinstance(runner.runner_hooks, HookManager)
|
||||||
|
assert runner.console == falyx_console
|
||||||
|
assert runner.command.options_manager == runner.options
|
||||||
|
assert runner.command.arg_parser.options_manager == runner.options
|
||||||
|
assert runner.command.options_manager == runner.options
|
||||||
|
assert runner.executor.options == runner.options
|
||||||
|
assert runner.executor.hooks == runner.runner_hooks
|
||||||
|
assert runner.options.get("summary", namespace_name="execution") is None
|
||||||
|
|
||||||
|
runner_no_parser = CommandRunner(command_with_no_parser)
|
||||||
|
assert runner_no_parser.command == command_with_no_parser
|
||||||
|
assert runner_no_parser.command.arg_parser is None
|
||||||
|
|
||||||
|
CommandRunner(command_with_no_parser)
|
||||||
|
with pytest.raises(
|
||||||
|
NotAFalyxError,
|
||||||
|
match="Command has no parser configured. Provide a custom_parser or CommandArgumentParser.",
|
||||||
|
):
|
||||||
|
await runner_no_parser.run("--summary")
|
||||||
|
|
||||||
|
runner_custom_parser = CommandRunner(command_with_custom_parser)
|
||||||
|
assert runner_custom_parser.command == command_with_custom_parser
|
||||||
|
assert runner_custom_parser.command.custom_parser is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_runner_initialization_with_custom_options(command_with_parser):
|
||||||
|
custom_options = OptionsManager([("default", {"summary": True})])
|
||||||
|
runner = CommandRunner(command_with_parser, options=custom_options)
|
||||||
|
assert runner.options == custom_options
|
||||||
|
assert runner.options.get("summary", namespace_name="default") is True
|
||||||
|
assert runner.command.options_manager == runner.options
|
||||||
|
assert runner.command.arg_parser.options_manager == runner.options
|
||||||
|
assert runner.command.options_manager == runner.options
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_runner_initialization_with_custom_console(command_with_parser):
|
||||||
|
custom_console = Console()
|
||||||
|
runner = CommandRunner(command_with_parser, console=custom_console)
|
||||||
|
assert runner.console == custom_console
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_runner_initialization_with_custom_hooks(command_with_parser):
|
||||||
|
custom_hooks = HookManager()
|
||||||
|
custom_hooks.register("before", lambda context: print("Before hook"))
|
||||||
|
runner = CommandRunner(command_with_parser, runner_hooks=custom_hooks)
|
||||||
|
assert runner.runner_hooks == custom_hooks
|
||||||
|
assert runner.executor.hooks == custom_hooks
|
||||||
|
assert runner.runner_hooks._hooks[HookType.BEFORE]
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_runner_initialization_with_all_bad_components(command_with_parser):
|
||||||
|
custom_options = "Not an OptionsManager"
|
||||||
|
custom_console = 23456
|
||||||
|
custom_hooks = "Not a HookManager"
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
NotAFalyxError, match="options must be an instance of OptionsManager"
|
||||||
|
):
|
||||||
|
CommandRunner(
|
||||||
|
command_with_parser,
|
||||||
|
options=custom_options,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
NotAFalyxError, match="console must be an instance of rich.Console"
|
||||||
|
):
|
||||||
|
CommandRunner(
|
||||||
|
command_with_parser,
|
||||||
|
console=custom_console,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
InvalidHookError, match="hooks must be an instance of HookManager"
|
||||||
|
):
|
||||||
|
CommandRunner(
|
||||||
|
command_with_parser,
|
||||||
|
runner_hooks=custom_hooks,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_command_runner_run(command_with_parser):
|
||||||
|
runner = CommandRunner(command_with_parser)
|
||||||
|
with falyx_console.capture() as capture:
|
||||||
|
result = await runner.run("--foo 42")
|
||||||
|
captured = Text.from_ansi(capture.get()).plain
|
||||||
|
assert result == "ok"
|
||||||
|
assert "Action executed with args:" in captured
|
||||||
|
assert "and kwargs:" in captured
|
||||||
|
assert "{'foo': 42}" in captured
|
||||||
|
|
||||||
|
falyx_console.clear()
|
||||||
|
with falyx_console.capture() as capture:
|
||||||
|
result = await runner.run(["--foo", "123"])
|
||||||
|
captured = Text.from_ansi(capture.get()).plain
|
||||||
|
assert result == "ok"
|
||||||
|
assert "Action executed with args:" in captured
|
||||||
|
assert "and kwargs:" in captured
|
||||||
|
assert "{'foo': 123}" in captured
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_command_runner_run_with_failing_action(command_with_failing_action):
|
||||||
|
runner = CommandRunner(command_with_failing_action)
|
||||||
|
with pytest.raises(RuntimeError, match="boom"):
|
||||||
|
await runner.run("--foo 42")
|
||||||
|
|
||||||
|
with pytest.raises(FalyxError, match="boom"):
|
||||||
|
await runner.run("--foo 42", wrap_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_command_runner_debug_statement(command_with_parser, caplog):
|
||||||
|
caplog.set_level("DEBUG")
|
||||||
|
runner = CommandRunner(command_with_parser)
|
||||||
|
await runner.run("--foo 42")
|
||||||
|
assert (
|
||||||
|
"Executing command 'Test Command' with args=(), kwargs={'foo': 42}" in caplog.text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_command_runner_run_with_retries_non_action(
|
||||||
|
command_with_failing_action, caplog
|
||||||
|
):
|
||||||
|
runner = CommandRunner(command_with_failing_action)
|
||||||
|
with pytest.raises(RuntimeError, match="boom"):
|
||||||
|
await runner.run("--foo 42 --retries 2")
|
||||||
|
|
||||||
|
assert "Retry requested, but action is not an Action instance." in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_command_runner_run_with_retries_with_action(
|
||||||
|
command_throwing_error, caplog
|
||||||
|
):
|
||||||
|
runner = CommandRunner(command_throwing_error)
|
||||||
|
with pytest.raises(asyncio.CancelledError, match="An error occurred in the action."):
|
||||||
|
await runner.run("Other")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="This is a ValueError."):
|
||||||
|
await runner.run("ValueError --retries 2")
|
||||||
|
|
||||||
|
assert "[throw_error] Retry attempt 1/2 failed due to 'ValueError'." in caplog.text
|
||||||
|
assert "[throw_error] Retry attempt 2/2 failed due to 'ValueError'." in caplog.text
|
||||||
|
assert "[throw_error] All 2 retries failed." in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_command_runner_run_with_retries_delay_with_action(
|
||||||
|
command_throwing_error, caplog
|
||||||
|
):
|
||||||
|
runner = CommandRunner(command_throwing_error)
|
||||||
|
with pytest.raises(asyncio.CancelledError, match="An error occurred in the action."):
|
||||||
|
await runner.run("Other")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="This is a ValueError."):
|
||||||
|
await runner.run("ValueError --retries 2 --retry-delay 1.0 --retry-backoff 2.0")
|
||||||
|
|
||||||
|
assert "[throw_error] Retry attempt 1/2 failed due to 'ValueError'." in caplog.text
|
||||||
|
assert "[throw_error] Retry attempt 2/2 failed due to 'ValueError'." in caplog.text
|
||||||
|
assert "[throw_error] All 2 retries failed." in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_command_runner_run_from_command_build_with_all_execution_options(
|
||||||
|
command_build_with_all_execution_options,
|
||||||
|
):
|
||||||
|
runner = CommandRunner.from_command(command_build_with_all_execution_options)
|
||||||
|
with falyx_console.capture() as capture:
|
||||||
|
result = await runner.run("--summary")
|
||||||
|
captured = Text.from_ansi(capture.get()).plain
|
||||||
|
assert result == "ok"
|
||||||
|
assert "Action executed with args:" in captured
|
||||||
|
assert "and kwargs:" in captured
|
||||||
|
assert "Execution History" in captured
|
||||||
|
|
||||||
|
with falyx_console.capture() as capture:
|
||||||
|
result = await runner.run("--summary", summary_last_result=True)
|
||||||
|
captured = Text.from_ansi(capture.get()).plain
|
||||||
|
assert result == "ok"
|
||||||
|
assert "Action executed with args:" in captured
|
||||||
|
assert "and kwargs:" in captured
|
||||||
|
assert "Command(key='T', description='Test Command' action=" in captured
|
||||||
|
assert "ok" in captured
|
||||||
|
|
||||||
|
with falyx_console.capture() as capture:
|
||||||
|
result = await runner.run("--summary", summary_last_result=False)
|
||||||
|
captured = Text.from_ansi(capture.get()).plain
|
||||||
|
assert result == "ok"
|
||||||
|
assert "Action executed with args:" in captured
|
||||||
|
assert "and kwargs:" in captured
|
||||||
|
assert "Execution History" in captured
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_command_runner_from_command_bad_command():
|
||||||
|
with pytest.raises(NotAFalyxError, match="command must be an instance of Command"):
|
||||||
|
CommandRunner.from_command("Not a Command")
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
InvalidHookError, match="runner_hooks must be an instance of HookManager"
|
||||||
|
):
|
||||||
|
CommandRunner.from_command(
|
||||||
|
Command(
|
||||||
|
key="T",
|
||||||
|
description="Test Command",
|
||||||
|
action=ok_action,
|
||||||
|
),
|
||||||
|
runner_hooks="Not a HookManager",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_command_runner_build():
|
||||||
|
runner = CommandRunner.build(
|
||||||
|
key="T",
|
||||||
|
description="Test Command",
|
||||||
|
action=ok_action,
|
||||||
|
execution_options=["summary", "retry"],
|
||||||
|
)
|
||||||
|
assert isinstance(runner, CommandRunner)
|
||||||
|
with falyx_console.capture() as capture:
|
||||||
|
result = await runner.run("--summary --retries 2")
|
||||||
|
captured = Text.from_ansi(capture.get()).plain
|
||||||
|
assert result == "ok"
|
||||||
|
assert "Action executed with args:" in captured
|
||||||
|
assert "and kwargs:" in captured
|
||||||
|
assert "Execution History" in captured
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_command_runner_build_with_bad_execution_options():
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match="Invalid ExecutionOption: 'invalid_option'. Must be one of:",
|
||||||
|
):
|
||||||
|
CommandRunner.build(
|
||||||
|
key="T",
|
||||||
|
description="Test Command",
|
||||||
|
action=ok_action,
|
||||||
|
execution_options=["summary", "invalid_option"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_command_runner_build_with_bad_runner_hooks():
|
||||||
|
with pytest.raises(
|
||||||
|
InvalidHookError, match="runner_hooks must be an instance of HookManager"
|
||||||
|
):
|
||||||
|
CommandRunner.build(
|
||||||
|
key="T",
|
||||||
|
description="Test Command",
|
||||||
|
action=ok_action,
|
||||||
|
runner_hooks="Not a HookManager",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_command_runner_uses_sys_argv(command_with_parser, monkeypatch):
|
||||||
|
runner = CommandRunner(command_with_parser)
|
||||||
|
test_args = ["program_name", "--foo", "42"]
|
||||||
|
monkeypatch.setattr(sys, "argv", test_args)
|
||||||
|
with falyx_console.capture() as capture:
|
||||||
|
result = await runner.run()
|
||||||
|
captured = Text.from_ansi(capture.get()).plain
|
||||||
|
assert result == "ok"
|
||||||
|
assert "Action executed with args:" in captured
|
||||||
|
assert "and kwargs:" in captured
|
||||||
|
assert "{'foo': 42}" in captured
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_command_runner_cli(command_with_parser):
|
||||||
|
runner = CommandRunner(command_with_parser)
|
||||||
|
with falyx_console.capture() as capture:
|
||||||
|
await runner.cli("--foo 42")
|
||||||
|
captured = Text.from_ansi(capture.get()).plain
|
||||||
|
assert "Action executed with args:" in captured
|
||||||
|
assert "and kwargs:" in captured
|
||||||
|
assert "{'foo': 42}" in captured
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_command_runnner_run_propogates_exeptions(command_throwing_error):
|
||||||
|
runner = CommandRunner(command_throwing_error)
|
||||||
|
|
||||||
|
with pytest.raises(QuitSignal, match="Quit signal triggered."):
|
||||||
|
await runner.run("QuitSignal")
|
||||||
|
|
||||||
|
with pytest.raises(BackSignal, match="Back signal triggered."):
|
||||||
|
await runner.run("BackSignal")
|
||||||
|
|
||||||
|
with pytest.raises(CancelSignal, match="Cancel signal triggered."):
|
||||||
|
await runner.run("CancelSignal")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="This is a ValueError."):
|
||||||
|
await runner.run("ValueError")
|
||||||
|
|
||||||
|
with pytest.raises(HelpSignal, match="Help signal triggered."):
|
||||||
|
await runner.run("HelpSignal")
|
||||||
|
|
||||||
|
with pytest.raises(asyncio.CancelledError, match="An error occurred in the action."):
|
||||||
|
await runner.run("Other")
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
CommandArgumentError,
|
||||||
|
match=r"\[E\] Failed to parse arguments: No closing quotation",
|
||||||
|
):
|
||||||
|
await runner.run("Mismatched'")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_command_runner_cli_with_failing_action(command_with_failing_action):
|
||||||
|
runner = CommandRunner(command_with_failing_action)
|
||||||
|
with pytest.raises(SystemExit, match="1"):
|
||||||
|
await runner.cli("--foo 42")
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit, match="2"):
|
||||||
|
await runner.cli("--foo 42 --bar 123")
|
||||||
|
|
||||||
|
with falyx_console.capture() as capture:
|
||||||
|
with pytest.raises(SystemExit, match="0"):
|
||||||
|
await runner.cli(["--help"])
|
||||||
|
captured = Text.from_ansi(capture.get()).plain
|
||||||
|
|
||||||
|
assert "usage: falyx" in captured
|
||||||
|
assert "--foo" in captured
|
||||||
|
assert "summary" in captured
|
||||||
|
assert "retries" in captured
|
||||||
|
assert "A business argument." in captured
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_command_runner_cli_exceptions(command_throwing_error):
|
||||||
|
runner = CommandRunner(command_throwing_error)
|
||||||
|
|
||||||
|
with falyx_console.capture() as capture:
|
||||||
|
with pytest.raises(SystemExit, match="0"):
|
||||||
|
await runner.cli(["--help"])
|
||||||
|
captured = Text.from_ansi(capture.get()).plain
|
||||||
|
assert "falyx [--help]" in captured
|
||||||
|
assert "usage:" in captured
|
||||||
|
assert "positional:" in captured
|
||||||
|
assert "options:" in captured
|
||||||
|
|
||||||
|
with falyx_console.capture() as capture:
|
||||||
|
with pytest.raises(SystemExit, match="2"):
|
||||||
|
await runner.cli(["--not-an-arg"])
|
||||||
|
captured = Text.from_ansi(capture.get()).plain
|
||||||
|
assert "falyx [--help]" in captured
|
||||||
|
assert "usage:" in captured
|
||||||
|
assert "positional:" in captured
|
||||||
|
assert "options:" in captured
|
||||||
|
falyx_console.clear()
|
||||||
|
|
||||||
|
with error_console.capture() as capture:
|
||||||
|
with pytest.raises(SystemExit, match="1"):
|
||||||
|
await runner.cli(["FalyxError"])
|
||||||
|
captured = Text.from_ansi(capture.get()).plain
|
||||||
|
assert "This is a FalyxError." in captured
|
||||||
|
assert "error:" in captured
|
||||||
|
falyx_console.clear()
|
||||||
|
|
||||||
|
with falyx_console.capture() as capture:
|
||||||
|
with pytest.raises(SystemExit, match="130"):
|
||||||
|
await runner.cli(["QuitSignal"])
|
||||||
|
captured = Text.from_ansi(capture.get()).plain
|
||||||
|
|
||||||
|
with falyx_console.capture() as capture:
|
||||||
|
with pytest.raises(SystemExit, match="1"):
|
||||||
|
await runner.cli(["BackSignal"])
|
||||||
|
captured = Text.from_ansi(capture.get()).plain
|
||||||
|
|
||||||
|
with falyx_console.capture() as capture:
|
||||||
|
with pytest.raises(SystemExit, match="1"):
|
||||||
|
await runner.cli(["CancelSignal"])
|
||||||
|
captured = Text.from_ansi(capture.get()).plain
|
||||||
|
|
||||||
|
with falyx_console.capture() as capture:
|
||||||
|
with pytest.raises(SystemExit, match="1"):
|
||||||
|
await runner.cli(["Other"])
|
||||||
|
captured = Text.from_ansi(capture.get()).plain
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_command_runner_cli_uses_sys_argv(command_with_parser, monkeypatch):
|
||||||
|
runner = CommandRunner(command_with_parser)
|
||||||
|
test_args = ["program_name", "--foo", "42"]
|
||||||
|
monkeypatch.setattr(sys, "argv", test_args)
|
||||||
|
with falyx_console.capture() as capture:
|
||||||
|
await runner.cli()
|
||||||
|
captured = Text.from_ansi(capture.get()).plain
|
||||||
|
assert "Action executed with args:" in captured
|
||||||
|
assert "and kwargs:" in captured
|
||||||
|
assert "{'foo': 42}" in captured
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_command_runner_run_error(command_with_parser):
|
||||||
|
runner = CommandRunner(command_with_parser)
|
||||||
|
with pytest.raises(FalyxError, match="requires either"):
|
||||||
|
await runner.run(["--foo", "42"], raise_on_error=False, wrap_errors=False)
|
||||||
|
await runner.run(["--foo", "42"], raise_on_error=False, wrap_errors=True)
|
||||||
|
await runner.run(["--foo", "42"], raise_on_error=True, wrap_errors=False)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user