Linting, pre-commit
This commit is contained in:
parent
4b1a9ef718
commit
e91654ca27
|
@ -15,4 +15,3 @@ build/
|
||||||
.vscode/
|
.vscode/
|
||||||
coverage.xml
|
coverage.xml
|
||||||
.coverage
|
.coverage
|
||||||
|
|
||||||
|
|
|
@ -8,15 +8,18 @@ from falyx import Action, ActionGroup, ChainedAction
|
||||||
def hello() -> None:
|
def hello() -> None:
|
||||||
print("Hello, world!")
|
print("Hello, world!")
|
||||||
|
|
||||||
|
|
||||||
hello = Action(name="hello_action", action=hello)
|
hello = Action(name="hello_action", action=hello)
|
||||||
|
|
||||||
# Actions can be run by themselves or as part of a command or pipeline
|
# Actions can be run by themselves or as part of a command or pipeline
|
||||||
asyncio.run(hello())
|
asyncio.run(hello())
|
||||||
|
|
||||||
|
|
||||||
# Actions are designed to be asynchronous first
|
# Actions are designed to be asynchronous first
|
||||||
async def goodbye() -> None:
|
async def goodbye() -> None:
|
||||||
print("Goodbye!")
|
print("Goodbye!")
|
||||||
|
|
||||||
|
|
||||||
goodbye = Action(name="goodbye_action", action=goodbye)
|
goodbye = Action(name="goodbye_action", action=goodbye)
|
||||||
|
|
||||||
asyncio.run(goodbye())
|
asyncio.run(goodbye())
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
from falyx import Falyx, ProcessAction
|
from falyx import Falyx, ProcessAction
|
||||||
from falyx.themes.colors import NordColors as nc
|
from falyx.themes.colors import NordColors as nc
|
||||||
from rich.console import Console
|
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
falyx = Falyx(title="🚀 Process Pool Demo")
|
falyx = Falyx(title="🚀 Process Pool Demo")
|
||||||
|
|
||||||
|
|
||||||
def generate_primes(n):
|
def generate_primes(n):
|
||||||
primes = []
|
primes = []
|
||||||
for num in range(2, n):
|
for num in range(2, n):
|
||||||
|
@ -13,6 +15,7 @@ def generate_primes(n):
|
||||||
console.print(f"Generated {len(primes)} primes up to {n}.", style=nc.GREEN)
|
console.print(f"Generated {len(primes)} primes up to {n}.", style=nc.GREEN)
|
||||||
return primes
|
return primes
|
||||||
|
|
||||||
|
|
||||||
# Will not block the event loop
|
# Will not block the event loop
|
||||||
heavy_action = ProcessAction("Prime Generator", generate_primes, args=(100_000,))
|
heavy_action = ProcessAction("Prime Generator", generate_primes, args=(100_000,))
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ from falyx.utils import setup_logging
|
||||||
|
|
||||||
setup_logging()
|
setup_logging()
|
||||||
|
|
||||||
|
|
||||||
# A flaky async step that fails randomly
|
# A flaky async step that fails randomly
|
||||||
async def flaky_step():
|
async def flaky_step():
|
||||||
await asyncio.sleep(0.2)
|
await asyncio.sleep(0.2)
|
||||||
|
@ -13,6 +14,7 @@ async def flaky_step():
|
||||||
raise RuntimeError("Random failure!")
|
raise RuntimeError("Random failure!")
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
# Create a retry handler
|
# Create a retry handler
|
||||||
step1 = Action(name="step_1", action=flaky_step, retry=True)
|
step1 = Action(name="step_1", action=flaky_step, retry=True)
|
||||||
step2 = Action(name="step_2", action=flaky_step, retry=True)
|
step2 = Action(name="step_2", action=flaky_step, retry=True)
|
||||||
|
|
|
@ -4,6 +4,7 @@ Falyx CLI Framework
|
||||||
Copyright (c) 2025 rtj.dev LLC.
|
Copyright (c) 2025 rtj.dev LLC.
|
||||||
Licensed under the MIT License. See LICENSE file for details.
|
Licensed under the MIT License. See LICENSE file for details.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from .action import Action, ActionGroup, ChainedAction, ProcessAction
|
from .action import Action, ActionGroup, ChainedAction, ProcessAction
|
||||||
|
|
|
@ -4,6 +4,7 @@ Falyx CLI Framework
|
||||||
Copyright (c) 2025 rtj.dev LLC.
|
Copyright (c) 2025 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 random
|
import random
|
||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
|
@ -131,7 +132,7 @@ async def main() -> None:
|
||||||
Action("Clean", foo.clean),
|
Action("Clean", foo.clean),
|
||||||
Action("Build", foo.build_package),
|
Action("Build", foo.build_package),
|
||||||
Action("Package", foo.package),
|
Action("Package", foo.package),
|
||||||
]
|
],
|
||||||
)
|
)
|
||||||
flx.add_command(
|
flx.add_command(
|
||||||
key="P",
|
key="P",
|
||||||
|
@ -150,7 +151,7 @@ async def main() -> None:
|
||||||
Action("Unit Tests", foo.run_tests),
|
Action("Unit Tests", foo.run_tests),
|
||||||
Action("Integration Tests", foo.run_integration_tests),
|
Action("Integration Tests", foo.run_integration_tests),
|
||||||
Action("Lint", foo.run_linter),
|
Action("Lint", foo.run_linter),
|
||||||
]
|
],
|
||||||
)
|
)
|
||||||
flx.add_command(
|
flx.add_command(
|
||||||
key="G",
|
key="G",
|
||||||
|
|
111
falyx/action.py
111
falyx/action.py
|
@ -59,13 +59,14 @@ class BaseAction(ABC):
|
||||||
(default: 'last_result').
|
(default: 'last_result').
|
||||||
_requires_injection (bool): Whether the action requires input injection.
|
_requires_injection (bool): Whether the action requires input injection.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
hooks: HookManager | None = None,
|
hooks: HookManager | None = None,
|
||||||
inject_last_result: bool = False,
|
inject_last_result: bool = False,
|
||||||
inject_last_result_as: str = "last_result",
|
inject_last_result_as: str = "last_result",
|
||||||
logging_hooks: bool = False,
|
logging_hooks: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.name = name
|
self.name = name
|
||||||
self.hooks = hooks or HookManager()
|
self.hooks = hooks or HookManager()
|
||||||
|
@ -156,18 +157,19 @@ class Action(BaseAction):
|
||||||
retry (bool, optional): Enable retry logic.
|
retry (bool, optional): Enable retry logic.
|
||||||
retry_policy (RetryPolicy, optional): Retry settings.
|
retry_policy (RetryPolicy, optional): Retry settings.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
action,
|
action,
|
||||||
rollback=None,
|
rollback=None,
|
||||||
args: tuple[Any, ...] = (),
|
args: tuple[Any, ...] = (),
|
||||||
kwargs: dict[str, Any] | None = None,
|
kwargs: dict[str, Any] | None = None,
|
||||||
hooks: HookManager | None = None,
|
hooks: HookManager | None = None,
|
||||||
inject_last_result: bool = False,
|
inject_last_result: bool = False,
|
||||||
inject_last_result_as: str = "last_result",
|
inject_last_result_as: str = "last_result",
|
||||||
retry: bool = False,
|
retry: bool = False,
|
||||||
retry_policy: RetryPolicy | None = None,
|
retry_policy: RetryPolicy | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(name, hooks, inject_last_result, inject_last_result_as)
|
super().__init__(name, hooks, inject_last_result, inject_last_result_as)
|
||||||
self.action = action
|
self.action = action
|
||||||
|
@ -264,10 +266,13 @@ class LiteralInputAction(Action):
|
||||||
Args:
|
Args:
|
||||||
value (Any): The static value to inject.
|
value (Any): The static value to inject.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, value: Any):
|
def __init__(self, value: Any):
|
||||||
self._value = value
|
self._value = value
|
||||||
|
|
||||||
async def literal(*args, **kwargs):
|
async def literal(*args, **kwargs):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
super().__init__("Input", literal)
|
super().__init__("Input", literal)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
|
@ -293,10 +298,13 @@ class FallbackAction(Action):
|
||||||
Args:
|
Args:
|
||||||
fallback (Any): The fallback value to use if last_result is None.
|
fallback (Any): The fallback value to use if last_result is None.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, fallback: Any):
|
def __init__(self, fallback: Any):
|
||||||
self._fallback = fallback
|
self._fallback = fallback
|
||||||
|
|
||||||
async def _fallback_logic(last_result):
|
async def _fallback_logic(last_result):
|
||||||
return last_result if last_result is not None else fallback
|
return last_result if last_result is not None else fallback
|
||||||
|
|
||||||
super().__init__(name="Fallback", action=_fallback_logic, inject_last_result=True)
|
super().__init__(name="Fallback", action=_fallback_logic, inject_last_result=True)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
|
@ -310,6 +318,7 @@ class FallbackAction(Action):
|
||||||
|
|
||||||
class ActionListMixin:
|
class ActionListMixin:
|
||||||
"""Mixin for managing a list of actions."""
|
"""Mixin for managing a list of actions."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.actions: list[BaseAction] = []
|
self.actions: list[BaseAction] = []
|
||||||
|
|
||||||
|
@ -360,15 +369,16 @@ class ChainedAction(BaseAction, ActionListMixin):
|
||||||
auto_inject (bool, optional): Auto-enable injection for subsequent actions.
|
auto_inject (bool, optional): Auto-enable injection for subsequent actions.
|
||||||
return_list (bool, optional): Whether to return a list of all results. False returns the last result.
|
return_list (bool, optional): Whether to return a list of all results. False returns the last result.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
actions: list[BaseAction | Any] | None = None,
|
actions: list[BaseAction | Any] | None = None,
|
||||||
hooks: HookManager | None = None,
|
hooks: HookManager | None = None,
|
||||||
inject_last_result: bool = False,
|
inject_last_result: bool = False,
|
||||||
inject_last_result_as: str = "last_result",
|
inject_last_result_as: str = "last_result",
|
||||||
auto_inject: bool = False,
|
auto_inject: bool = False,
|
||||||
return_list: bool = False,
|
return_list: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(name, hooks, inject_last_result, inject_last_result_as)
|
super().__init__(name, hooks, inject_last_result, inject_last_result_as)
|
||||||
ActionListMixin.__init__(self)
|
ActionListMixin.__init__(self)
|
||||||
|
@ -378,7 +388,9 @@ class ChainedAction(BaseAction, ActionListMixin):
|
||||||
self.set_actions(actions)
|
self.set_actions(actions)
|
||||||
|
|
||||||
def _wrap_literal_if_needed(self, action: BaseAction | Any) -> BaseAction:
|
def _wrap_literal_if_needed(self, action: BaseAction | Any) -> BaseAction:
|
||||||
return LiteralInputAction(action) if not isinstance(action, BaseAction) else action
|
return (
|
||||||
|
LiteralInputAction(action) if not isinstance(action, BaseAction) else action
|
||||||
|
)
|
||||||
|
|
||||||
def add_action(self, action: BaseAction | Any) -> None:
|
def add_action(self, action: BaseAction | Any) -> None:
|
||||||
action = self._wrap_literal_if_needed(action)
|
action = self._wrap_literal_if_needed(action)
|
||||||
|
@ -408,23 +420,35 @@ class ChainedAction(BaseAction, ActionListMixin):
|
||||||
|
|
||||||
for index, action in enumerate(self.actions):
|
for index, action in enumerate(self.actions):
|
||||||
if action._skip_in_chain:
|
if action._skip_in_chain:
|
||||||
logger.debug("[%s] ⚠️ Skipping consumed action '%s'", self.name, action.name)
|
logger.debug(
|
||||||
|
"[%s] ⚠️ Skipping consumed action '%s'", self.name, action.name
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
shared_context.current_index = index
|
shared_context.current_index = index
|
||||||
prepared = action.prepare_for_chain(shared_context)
|
prepared = action.prepare_for_chain(shared_context)
|
||||||
last_result = shared_context.last_result()
|
last_result = shared_context.last_result()
|
||||||
try:
|
try:
|
||||||
if self.requires_io_injection() and last_result is not None:
|
if self.requires_io_injection() and last_result is not None:
|
||||||
result = await prepared(**{prepared.inject_last_result_as: last_result})
|
result = await prepared(
|
||||||
|
**{prepared.inject_last_result_as: last_result}
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
result = await prepared(*args, **updated_kwargs)
|
result = await prepared(*args, **updated_kwargs)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
if index + 1 < len(self.actions) and isinstance(self.actions[index + 1], FallbackAction):
|
if index + 1 < len(self.actions) and isinstance(
|
||||||
logger.warning("[%s] ⚠️ Fallback triggered: %s, recovering with fallback '%s'.",
|
self.actions[index + 1], FallbackAction
|
||||||
self.name, error, self.actions[index + 1].name)
|
):
|
||||||
|
logger.warning(
|
||||||
|
"[%s] ⚠️ Fallback triggered: %s, recovering with fallback '%s'.",
|
||||||
|
self.name,
|
||||||
|
error,
|
||||||
|
self.actions[index + 1].name,
|
||||||
|
)
|
||||||
shared_context.add_result(None)
|
shared_context.add_result(None)
|
||||||
context.extra["results"].append(None)
|
context.extra["results"].append(None)
|
||||||
fallback = self.actions[index + 1].prepare_for_chain(shared_context)
|
fallback = self.actions[index + 1].prepare_for_chain(
|
||||||
|
shared_context
|
||||||
|
)
|
||||||
result = await fallback()
|
result = await fallback()
|
||||||
fallback._skip_in_chain = True
|
fallback._skip_in_chain = True
|
||||||
else:
|
else:
|
||||||
|
@ -434,7 +458,9 @@ class ChainedAction(BaseAction, ActionListMixin):
|
||||||
context.extra["rollback_stack"].append(prepared)
|
context.extra["rollback_stack"].append(prepared)
|
||||||
|
|
||||||
all_results = context.extra["results"]
|
all_results = context.extra["results"]
|
||||||
assert all_results, f"[{self.name}] No results captured. Something seriously went wrong."
|
assert (
|
||||||
|
all_results
|
||||||
|
), f"[{self.name}] No results captured. Something seriously went wrong."
|
||||||
context.result = all_results if self.return_list else all_results[-1]
|
context.result = all_results if self.return_list else all_results[-1]
|
||||||
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||||
return context.result
|
return context.result
|
||||||
|
@ -528,13 +554,14 @@ class ActionGroup(BaseAction, ActionListMixin):
|
||||||
inject_last_result (bool, optional): Whether to inject last results into kwargs by default.
|
inject_last_result (bool, optional): Whether to inject last results into kwargs by default.
|
||||||
inject_last_result_as (str, optional): Key name for injection.
|
inject_last_result_as (str, optional): Key name for injection.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
actions: list[BaseAction] | None = None,
|
actions: list[BaseAction] | None = None,
|
||||||
hooks: HookManager | None = None,
|
hooks: HookManager | None = None,
|
||||||
inject_last_result: bool = False,
|
inject_last_result: bool = False,
|
||||||
inject_last_result_as: str = "last_result",
|
inject_last_result_as: str = "last_result",
|
||||||
):
|
):
|
||||||
super().__init__(name, hooks, inject_last_result, inject_last_result_as)
|
super().__init__(name, hooks, inject_last_result, inject_last_result_as)
|
||||||
ActionListMixin.__init__(self)
|
ActionListMixin.__init__(self)
|
||||||
|
@ -554,6 +581,7 @@ class ActionGroup(BaseAction, ActionListMixin):
|
||||||
extra={"results": [], "errors": []},
|
extra={"results": [], "errors": []},
|
||||||
shared_context=shared_context,
|
shared_context=shared_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def run_one(action: BaseAction):
|
async def run_one(action: BaseAction):
|
||||||
try:
|
try:
|
||||||
prepared = action.prepare_for_group(shared_context)
|
prepared = action.prepare_for_group(shared_context)
|
||||||
|
@ -692,7 +720,9 @@ class ProcessAction(BaseAction):
|
||||||
er.record(context)
|
er.record(context)
|
||||||
|
|
||||||
async def preview(self, parent: Tree | None = None):
|
async def preview(self, parent: Tree | None = None):
|
||||||
label = [f"[{OneColors.DARK_YELLOW_b}]🧠 ProcessAction (new process)[/] '{self.name}'"]
|
label = [
|
||||||
|
f"[{OneColors.DARK_YELLOW_b}]🧠 ProcessAction (new process)[/] '{self.name}'"
|
||||||
|
]
|
||||||
if self.inject_last_result:
|
if self.inject_last_result:
|
||||||
label.append(f" [dim](injects '{self.inject_last_result_as}')[/dim]")
|
label.append(f" [dim](injects '{self.inject_last_result_as}')[/dim]")
|
||||||
if parent:
|
if parent:
|
||||||
|
@ -703,6 +733,7 @@ class ProcessAction(BaseAction):
|
||||||
def _validate_pickleable(self, obj: Any) -> bool:
|
def _validate_pickleable(self, obj: Any) -> bool:
|
||||||
try:
|
try:
|
||||||
import pickle
|
import pickle
|
||||||
|
|
||||||
pickle.dumps(obj)
|
pickle.dumps(obj)
|
||||||
return True
|
return True
|
||||||
except (pickle.PicklingError, TypeError):
|
except (pickle.PicklingError, TypeError):
|
||||||
|
|
|
@ -45,11 +45,7 @@ class BottomBar:
|
||||||
def space(self) -> int:
|
def space(self) -> int:
|
||||||
return self.console.width // self.columns
|
return self.console.width // self.columns
|
||||||
|
|
||||||
def add_custom(
|
def add_custom(self, name: str, render_fn: Callable[[], HTML]) -> None:
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
render_fn: Callable[[], HTML]
|
|
||||||
) -> None:
|
|
||||||
"""Add a custom render function to the bottom bar."""
|
"""Add a custom render function to the bottom bar."""
|
||||||
if not callable(render_fn):
|
if not callable(render_fn):
|
||||||
raise ValueError("`render_fn` must be callable")
|
raise ValueError("`render_fn` must be callable")
|
||||||
|
@ -63,9 +59,7 @@ class BottomBar:
|
||||||
bg: str = OneColors.WHITE,
|
bg: str = OneColors.WHITE,
|
||||||
) -> None:
|
) -> None:
|
||||||
def render():
|
def render():
|
||||||
return HTML(
|
return HTML(f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>")
|
||||||
f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>"
|
|
||||||
)
|
|
||||||
|
|
||||||
self._add_named(name, render)
|
self._add_named(name, render)
|
||||||
|
|
||||||
|
@ -85,9 +79,7 @@ class BottomBar:
|
||||||
get_value_ = self._value_getters[name]
|
get_value_ = self._value_getters[name]
|
||||||
current_ = get_value_()
|
current_ = get_value_()
|
||||||
text = f"{label}: {current_}"
|
text = f"{label}: {current_}"
|
||||||
return HTML(
|
return HTML(f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>")
|
||||||
f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>"
|
|
||||||
)
|
|
||||||
|
|
||||||
self._add_named(name, render)
|
self._add_named(name, render)
|
||||||
|
|
||||||
|
@ -114,9 +106,7 @@ class BottomBar:
|
||||||
f"Current value {current_value} is greater than total value {total}"
|
f"Current value {current_value} is greater than total value {total}"
|
||||||
)
|
)
|
||||||
text = f"{label}: {current_value}/{total}"
|
text = f"{label}: {current_value}/{total}"
|
||||||
return HTML(
|
return HTML(f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>")
|
||||||
f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>"
|
|
||||||
)
|
|
||||||
|
|
||||||
self._add_named(name, render)
|
self._add_named(name, render)
|
||||||
|
|
||||||
|
@ -138,7 +128,9 @@ class BottomBar:
|
||||||
if key in self.toggle_keys:
|
if key in self.toggle_keys:
|
||||||
raise ValueError(f"Key {key} is already used as a toggle")
|
raise ValueError(f"Key {key} is already used as a toggle")
|
||||||
if self.key_validator and not self.key_validator(key):
|
if self.key_validator and not self.key_validator(key):
|
||||||
raise ValueError(f"Key '{key}' conflicts with existing command, toggle, or reserved key.")
|
raise ValueError(
|
||||||
|
f"Key '{key}' conflicts with existing command, toggle, or reserved key."
|
||||||
|
)
|
||||||
self._value_getters[key] = get_state
|
self._value_getters[key] = get_state
|
||||||
self.toggle_keys.append(key)
|
self.toggle_keys.append(key)
|
||||||
|
|
||||||
|
@ -147,9 +139,7 @@ class BottomBar:
|
||||||
color = bg_on if get_state_() else bg_off
|
color = bg_on if get_state_() else bg_off
|
||||||
status = "ON" if get_state_() else "OFF"
|
status = "ON" if get_state_() else "OFF"
|
||||||
text = f"({key.upper()}) {label}: {status}"
|
text = f"({key.upper()}) {label}: {status}"
|
||||||
return HTML(
|
return HTML(f"<style bg='{color}' fg='{fg}'>{text:^{self.space}}</style>")
|
||||||
f"<style bg='{color}' fg='{fg}'>{text:^{self.space}}</style>"
|
|
||||||
)
|
|
||||||
|
|
||||||
self._add_named(key, render)
|
self._add_named(key, render)
|
||||||
|
|
||||||
|
|
|
@ -44,7 +44,7 @@ class Command(BaseModel):
|
||||||
"""
|
"""
|
||||||
Represents a selectable command in a Falyx menu system.
|
Represents a selectable command in a Falyx menu system.
|
||||||
|
|
||||||
A Command wraps an executable action (function, coroutine, or BaseAction)
|
A Command wraps an executable action (function, coroutine, or BaseAction)
|
||||||
and enhances it with:
|
and enhances it with:
|
||||||
|
|
||||||
- Lifecycle hooks (before, success, error, after, teardown)
|
- Lifecycle hooks (before, success, error, after, teardown)
|
||||||
|
@ -91,6 +91,7 @@ class Command(BaseModel):
|
||||||
result: Property exposing the last result.
|
result: Property exposing the last result.
|
||||||
log_summary(): Summarizes execution details to the console.
|
log_summary(): Summarizes execution details to the console.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
key: str
|
key: str
|
||||||
description: str
|
description: str
|
||||||
action: BaseAction | Callable[[], Any] = _noop
|
action: BaseAction | Callable[[], Any] = _noop
|
||||||
|
@ -127,12 +128,16 @@ class Command(BaseModel):
|
||||||
elif self.retry_policy and isinstance(self.action, Action):
|
elif self.retry_policy and isinstance(self.action, Action):
|
||||||
self.action.set_retry_policy(self.retry_policy)
|
self.action.set_retry_policy(self.retry_policy)
|
||||||
elif self.retry:
|
elif self.retry:
|
||||||
logger.warning(f"[Command:{self.key}] Retry requested, but action is not an Action instance.")
|
logger.warning(
|
||||||
|
f"[Command:{self.key}] Retry requested, but action is not an Action instance."
|
||||||
|
)
|
||||||
if self.retry_all and isinstance(self.action, BaseAction):
|
if self.retry_all and isinstance(self.action, BaseAction):
|
||||||
self.retry_policy.enabled = True
|
self.retry_policy.enabled = True
|
||||||
enable_retries_recursively(self.action, self.retry_policy)
|
enable_retries_recursively(self.action, self.retry_policy)
|
||||||
elif self.retry_all:
|
elif self.retry_all:
|
||||||
logger.warning(f"[Command:{self.key}] Retry all requested, but action is not a BaseAction instance.")
|
logger.warning(
|
||||||
|
f"[Command:{self.key}] Retry all requested, but action is not a BaseAction instance."
|
||||||
|
)
|
||||||
|
|
||||||
if self.logging_hooks and isinstance(self.action, BaseAction):
|
if self.logging_hooks and isinstance(self.action, BaseAction):
|
||||||
register_debug_hooks(self.action.hooks)
|
register_debug_hooks(self.action.hooks)
|
||||||
|
@ -149,7 +154,11 @@ class Command(BaseModel):
|
||||||
if isinstance(self.action, BaseIOAction):
|
if isinstance(self.action, BaseIOAction):
|
||||||
return True
|
return True
|
||||||
elif isinstance(self.action, ChainedAction):
|
elif isinstance(self.action, ChainedAction):
|
||||||
return isinstance(self.action.actions[0], BaseIOAction) if self.action.actions else False
|
return (
|
||||||
|
isinstance(self.action.actions[0], BaseIOAction)
|
||||||
|
if self.action.actions
|
||||||
|
else False
|
||||||
|
)
|
||||||
elif isinstance(self.action, ActionGroup):
|
elif isinstance(self.action, ActionGroup):
|
||||||
return any(isinstance(action, BaseIOAction) for action in self.action.actions)
|
return any(isinstance(action, BaseIOAction) for action in self.action.actions)
|
||||||
return False
|
return False
|
||||||
|
@ -164,8 +173,10 @@ class Command(BaseModel):
|
||||||
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 __str__(self):
|
def __str__(self):
|
||||||
return (f"Command(key='{self.key}', description='{self.description}' "
|
return (
|
||||||
f"action='{self.action}')")
|
f"Command(key='{self.key}', description='{self.description}' "
|
||||||
|
f"action='{self.action}')"
|
||||||
|
)
|
||||||
|
|
||||||
async def __call__(self, *args, **kwargs):
|
async def __call__(self, *args, **kwargs):
|
||||||
"""Run the action with full hook lifecycle, timing, and error handling."""
|
"""Run the action with full hook lifecycle, timing, and error handling."""
|
||||||
|
@ -208,9 +219,7 @@ class Command(BaseModel):
|
||||||
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([
|
return FormattedText([("class:confirm", self.confirm_message)])
|
||||||
("class:confirm", self.confirm_message)
|
|
||||||
])
|
|
||||||
|
|
||||||
action_name = getattr(self.action, "__name__", None)
|
action_name = getattr(self.action, "__name__", None)
|
||||||
if isinstance(self.action, BaseAction):
|
if isinstance(self.action, BaseAction):
|
||||||
|
@ -225,7 +234,9 @@ class Command(BaseModel):
|
||||||
prompt.append(("class:confirm", f"(calls `{action_name}`) "))
|
prompt.append(("class:confirm", f"(calls `{action_name}`) "))
|
||||||
|
|
||||||
if self.args or self.kwargs:
|
if self.args or self.kwargs:
|
||||||
prompt.append((OneColors.DARK_YELLOW, f"with args={self.args}, kwargs={self.kwargs} "))
|
prompt.append(
|
||||||
|
(OneColors.DARK_YELLOW, f"with args={self.args}, kwargs={self.kwargs} ")
|
||||||
|
)
|
||||||
|
|
||||||
return FormattedText(prompt)
|
return FormattedText(prompt)
|
||||||
|
|
||||||
|
@ -248,4 +259,6 @@ class Command(BaseModel):
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
console.print(f"{label}")
|
console.print(f"{label}")
|
||||||
console.print(f"[{OneColors.DARK_RED}]⚠️ Action is not callable or lacks a preview method.[/]")
|
console.print(
|
||||||
|
f"[{OneColors.DARK_RED}]⚠️ Action is not callable or lacks a preview method.[/]"
|
||||||
|
)
|
||||||
|
|
|
@ -69,7 +69,6 @@ def loader(file_path: str) -> list[dict[str, Any]]:
|
||||||
if not isinstance(raw_config, list):
|
if not isinstance(raw_config, list):
|
||||||
raise ValueError("Configuration file must contain a list of command definitions.")
|
raise ValueError("Configuration file must contain a list of command definitions.")
|
||||||
|
|
||||||
|
|
||||||
required = ["key", "description", "action"]
|
required = ["key", "description", "action"]
|
||||||
commands = []
|
commands = []
|
||||||
for entry in raw_config:
|
for entry in raw_config:
|
||||||
|
@ -80,8 +79,9 @@ def loader(file_path: str) -> list[dict[str, Any]]:
|
||||||
command_dict = {
|
command_dict = {
|
||||||
"key": entry["key"],
|
"key": entry["key"],
|
||||||
"description": entry["description"],
|
"description": entry["description"],
|
||||||
"action": wrap_if_needed(import_action(entry["action"]),
|
"action": wrap_if_needed(
|
||||||
name=entry["description"]),
|
import_action(entry["action"]), name=entry["description"]
|
||||||
|
),
|
||||||
"args": tuple(entry.get("args", ())),
|
"args": tuple(entry.get("args", ())),
|
||||||
"kwargs": entry.get("kwargs", {}),
|
"kwargs": entry.get("kwargs", {}),
|
||||||
"hidden": entry.get("hidden", False),
|
"hidden": entry.get("hidden", False),
|
||||||
|
|
|
@ -87,7 +87,11 @@ class ExecutionContext(BaseModel):
|
||||||
def to_log_line(self) -> str:
|
def to_log_line(self) -> str:
|
||||||
"""Structured flat-line format for logging and metrics."""
|
"""Structured flat-line format for logging and metrics."""
|
||||||
duration_str = f"{self.duration:.3f}s" if self.duration is not None else "n/a"
|
duration_str = f"{self.duration:.3f}s" if self.duration is not None else "n/a"
|
||||||
exception_str = f"{type(self.exception).__name__}: {self.exception}" if self.exception else "None"
|
exception_str = (
|
||||||
|
f"{type(self.exception).__name__}: {self.exception}"
|
||||||
|
if self.exception
|
||||||
|
else "None"
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
f"[{self.name}] status={self.status} duration={duration_str} "
|
f"[{self.name}] status={self.status} duration={duration_str} "
|
||||||
f"result={repr(self.result)} exception={exception_str}"
|
f"result={repr(self.result)} exception={exception_str}"
|
||||||
|
@ -95,7 +99,11 @@ class ExecutionContext(BaseModel):
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
duration_str = f"{self.duration:.3f}s" if self.duration is not None else "n/a"
|
duration_str = f"{self.duration:.3f}s" if self.duration is not None else "n/a"
|
||||||
result_str = f"Result: {repr(self.result)}" if self.success else f"Exception: {self.exception}"
|
result_str = (
|
||||||
|
f"Result: {repr(self.result)}"
|
||||||
|
if self.success
|
||||||
|
else f"Exception: {self.exception}"
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
f"<ExecutionContext '{self.name}' | {self.status} | "
|
f"<ExecutionContext '{self.name}' | {self.status} | "
|
||||||
f"Duration: {duration_str} | {result_str}>"
|
f"Duration: {duration_str} | {result_str}>"
|
||||||
|
@ -153,6 +161,7 @@ class SharedContext(BaseModel):
|
||||||
f"Errors: {self.errors}>"
|
f"Errors: {self.errors}>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
|
|
@ -22,6 +22,6 @@ class NotAFalyxError(FalyxError):
|
||||||
class CircuitBreakerOpen(FalyxError):
|
class CircuitBreakerOpen(FalyxError):
|
||||||
"""Exception raised when the circuit breaker is open."""
|
"""Exception raised when the circuit breaker is open."""
|
||||||
|
|
||||||
|
|
||||||
class EmptyChainError(FalyxError):
|
class EmptyChainError(FalyxError):
|
||||||
"""Exception raised when the chain is empty."""
|
"""Exception raised when the chain is empty."""
|
||||||
|
|
||||||
|
|
|
@ -53,8 +53,16 @@ class ExecutionRegistry:
|
||||||
table.add_column("Result / Exception", overflow="fold")
|
table.add_column("Result / Exception", overflow="fold")
|
||||||
|
|
||||||
for ctx in cls.get_all():
|
for ctx in cls.get_all():
|
||||||
start = datetime.fromtimestamp(ctx.start_time).strftime("%H:%M:%S") if ctx.start_time else "n/a"
|
start = (
|
||||||
end = datetime.fromtimestamp(ctx.end_time).strftime("%H:%M:%S") if ctx.end_time else "n/a"
|
datetime.fromtimestamp(ctx.start_time).strftime("%H:%M:%S")
|
||||||
|
if ctx.start_time
|
||||||
|
else "n/a"
|
||||||
|
)
|
||||||
|
end = (
|
||||||
|
datetime.fromtimestamp(ctx.end_time).strftime("%H:%M:%S")
|
||||||
|
if ctx.end_time
|
||||||
|
else "n/a"
|
||||||
|
)
|
||||||
duration = f"{ctx.duration:.3f}s" if ctx.duration else "n/a"
|
duration = f"{ctx.duration:.3f}s" if ctx.duration else "n/a"
|
||||||
|
|
||||||
if ctx.exception:
|
if ctx.exception:
|
||||||
|
@ -74,6 +82,8 @@ class ExecutionRegistry:
|
||||||
def get_history_action(cls) -> "Action":
|
def get_history_action(cls) -> "Action":
|
||||||
"""Return an Action that prints the execution summary."""
|
"""Return an Action that prints the execution summary."""
|
||||||
from falyx.action import Action
|
from falyx.action import Action
|
||||||
|
|
||||||
async def show_history():
|
async def show_history():
|
||||||
cls.summary()
|
cls.summary()
|
||||||
|
|
||||||
return Action(name="View Execution History", action=show_history)
|
return Action(name="View Execution History", action=show_history)
|
||||||
|
|
248
falyx/falyx.py
248
falyx/falyx.py
|
@ -42,16 +42,25 @@ from falyx.bottom_bar import BottomBar
|
||||||
from falyx.command import Command
|
from falyx.command import Command
|
||||||
from falyx.context import ExecutionContext
|
from falyx.context import ExecutionContext
|
||||||
from falyx.debug import log_after, log_before, log_error, log_success
|
from falyx.debug import log_after, log_before, log_error, log_success
|
||||||
from falyx.exceptions import (CommandAlreadyExistsError, FalyxError,
|
from falyx.exceptions import (
|
||||||
InvalidActionError, NotAFalyxError)
|
CommandAlreadyExistsError,
|
||||||
|
FalyxError,
|
||||||
|
InvalidActionError,
|
||||||
|
NotAFalyxError,
|
||||||
|
)
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
from falyx.hook_manager import Hook, HookManager, HookType
|
from falyx.hook_manager import Hook, HookManager, HookType
|
||||||
from falyx.options_manager import OptionsManager
|
from falyx.options_manager import OptionsManager
|
||||||
from falyx.parsers import get_arg_parsers
|
from falyx.parsers import get_arg_parsers
|
||||||
from falyx.retry import RetryPolicy
|
from falyx.retry import RetryPolicy
|
||||||
from falyx.themes.colors import OneColors, get_nord_theme
|
from falyx.themes.colors import OneColors, get_nord_theme
|
||||||
from falyx.utils import (CaseInsensitiveDict, async_confirm, chunks,
|
from falyx.utils import (
|
||||||
get_program_invocation, logger)
|
CaseInsensitiveDict,
|
||||||
|
async_confirm,
|
||||||
|
chunks,
|
||||||
|
get_program_invocation,
|
||||||
|
logger,
|
||||||
|
)
|
||||||
from falyx.version import __version__
|
from falyx.version import __version__
|
||||||
|
|
||||||
|
|
||||||
|
@ -59,8 +68,8 @@ class Falyx:
|
||||||
"""
|
"""
|
||||||
Main menu controller for Falyx CLI applications.
|
Main menu controller for Falyx CLI applications.
|
||||||
|
|
||||||
Falyx orchestrates the full lifecycle of an interactive menu system,
|
Falyx orchestrates the full lifecycle of an interactive menu system,
|
||||||
handling user input, command execution, error recovery, and structured
|
handling user input, command execution, error recovery, and structured
|
||||||
CLI workflows.
|
CLI workflows.
|
||||||
|
|
||||||
Key Features:
|
Key Features:
|
||||||
|
@ -101,6 +110,7 @@ class Falyx:
|
||||||
build_default_table(): Construct the standard Rich table layout.
|
build_default_table(): Construct the standard Rich table layout.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
title: str | Markdown = "Menu",
|
title: str | Markdown = "Menu",
|
||||||
|
@ -117,6 +127,7 @@ class Falyx:
|
||||||
always_confirm: bool = False,
|
always_confirm: bool = False,
|
||||||
cli_args: Namespace | None = None,
|
cli_args: Namespace | None = None,
|
||||||
options: OptionsManager | None = None,
|
options: OptionsManager | None = None,
|
||||||
|
render_menu: Callable[["Falyx"], None] | None = None,
|
||||||
custom_table: Callable[["Falyx"], Table] | Table | None = None,
|
custom_table: Callable[["Falyx"], Table] | Table | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initializes the Falyx object."""
|
"""Initializes the Falyx object."""
|
||||||
|
@ -125,8 +136,12 @@ class Falyx:
|
||||||
self.columns: int = columns
|
self.columns: int = columns
|
||||||
self.commands: dict[str, Command] = CaseInsensitiveDict()
|
self.commands: dict[str, Command] = CaseInsensitiveDict()
|
||||||
self.exit_command: Command = self._get_exit_command()
|
self.exit_command: Command = self._get_exit_command()
|
||||||
self.history_command: Command | None = self._get_history_command() if include_history_command else None
|
self.history_command: Command | None = (
|
||||||
self.help_command: Command | None = self._get_help_command() if include_help_command else None
|
self._get_history_command() if include_history_command else None
|
||||||
|
)
|
||||||
|
self.help_command: Command | None = (
|
||||||
|
self._get_help_command() if include_help_command else None
|
||||||
|
)
|
||||||
self.console: Console = Console(color_system="truecolor", theme=get_nord_theme())
|
self.console: Console = Console(color_system="truecolor", theme=get_nord_theme())
|
||||||
self.welcome_message: str | Markdown | dict[str, Any] = welcome_message
|
self.welcome_message: str | Markdown | dict[str, Any] = welcome_message
|
||||||
self.exit_message: str | Markdown | dict[str, Any] = exit_message
|
self.exit_message: str | Markdown | dict[str, Any] = exit_message
|
||||||
|
@ -138,15 +153,16 @@ class Falyx:
|
||||||
self._never_confirm: bool = never_confirm
|
self._never_confirm: bool = never_confirm
|
||||||
self._always_confirm: bool = always_confirm
|
self._always_confirm: bool = always_confirm
|
||||||
self.cli_args: Namespace | None = cli_args
|
self.cli_args: Namespace | None = cli_args
|
||||||
|
self.render_menu: Callable[["Falyx"], None] | None = render_menu
|
||||||
self.custom_table: Callable[["Falyx"], Table] | Table | None = custom_table
|
self.custom_table: Callable[["Falyx"], Table] | Table | None = custom_table
|
||||||
self.set_options(cli_args, options)
|
self.set_options(cli_args, options)
|
||||||
self._session: PromptSession | None = None
|
self._session: PromptSession | None = None
|
||||||
|
|
||||||
def set_options(
|
def set_options(
|
||||||
self,
|
self,
|
||||||
cli_args: Namespace | None,
|
cli_args: Namespace | None,
|
||||||
options: OptionsManager | None = None,
|
options: OptionsManager | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Checks if the options are set correctly."""
|
"""Checks if the options are set correctly."""
|
||||||
self.options: OptionsManager = options or OptionsManager()
|
self.options: OptionsManager = options or OptionsManager()
|
||||||
if not cli_args and not options:
|
if not cli_args and not options:
|
||||||
|
@ -155,7 +171,9 @@ class Falyx:
|
||||||
if options and not cli_args:
|
if options and not cli_args:
|
||||||
raise FalyxError("Options are set, but CLI arguments are not.")
|
raise FalyxError("Options are set, but CLI arguments are not.")
|
||||||
|
|
||||||
assert isinstance(cli_args, Namespace), "CLI arguments must be a Namespace object."
|
assert isinstance(
|
||||||
|
cli_args, Namespace
|
||||||
|
), "CLI arguments must be a Namespace object."
|
||||||
if options is None:
|
if options is None:
|
||||||
self.options.from_namespace(cli_args, "cli_args")
|
self.options.from_namespace(cli_args, "cli_args")
|
||||||
|
|
||||||
|
@ -240,27 +258,27 @@ class Falyx:
|
||||||
f"[{command.color}]{command.key}[/]",
|
f"[{command.color}]{command.key}[/]",
|
||||||
", ".join(command.aliases) if command.aliases else "None",
|
", ".join(command.aliases) if command.aliases else "None",
|
||||||
help_text,
|
help_text,
|
||||||
", ".join(command.tags) if command.tags else "None"
|
", ".join(command.tags) if command.tags else "None",
|
||||||
)
|
)
|
||||||
|
|
||||||
table.add_row(
|
table.add_row(
|
||||||
f"[{self.exit_command.color}]{self.exit_command.key}[/]",
|
f"[{self.exit_command.color}]{self.exit_command.key}[/]",
|
||||||
", ".join(self.exit_command.aliases),
|
", ".join(self.exit_command.aliases),
|
||||||
"Exit this menu or program"
|
"Exit this menu or program",
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.history_command:
|
if self.history_command:
|
||||||
table.add_row(
|
table.add_row(
|
||||||
f"[{self.history_command.color}]{self.history_command.key}[/]",
|
f"[{self.history_command.color}]{self.history_command.key}[/]",
|
||||||
", ".join(self.history_command.aliases),
|
", ".join(self.history_command.aliases),
|
||||||
"History of executed actions"
|
"History of executed actions",
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.help_command:
|
if self.help_command:
|
||||||
table.add_row(
|
table.add_row(
|
||||||
f"[{self.help_command.color}]{self.help_command.key}[/]",
|
f"[{self.help_command.color}]{self.help_command.key}[/]",
|
||||||
", ".join(self.help_command.aliases),
|
", ".join(self.help_command.aliases),
|
||||||
"Show this help menu"
|
"Show this help menu",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.console.print(table, justify="center")
|
self.console.print(table, justify="center")
|
||||||
|
@ -274,6 +292,7 @@ class Falyx:
|
||||||
action=self._show_help,
|
action=self._show_help,
|
||||||
color=OneColors.LIGHT_YELLOW,
|
color=OneColors.LIGHT_YELLOW,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_completer(self) -> WordCompleter:
|
def _get_completer(self) -> WordCompleter:
|
||||||
"""Completer to provide auto-completion for the menu commands."""
|
"""Completer to provide auto-completion for the menu commands."""
|
||||||
keys = [self.exit_command.key]
|
keys = [self.exit_command.key]
|
||||||
|
@ -350,18 +369,22 @@ class Falyx:
|
||||||
return self._bottom_bar
|
return self._bottom_bar
|
||||||
|
|
||||||
@bottom_bar.setter
|
@bottom_bar.setter
|
||||||
def bottom_bar(self, bottom_bar: BottomBar | str | Callable[[], Any] | None) -> None:
|
def bottom_bar(self, bottom_bar: BottomBar | str | Callable[[], Any] | None) -> None:
|
||||||
"""Sets the bottom bar for the menu."""
|
"""Sets the bottom bar for the menu."""
|
||||||
if bottom_bar is None:
|
if bottom_bar is None:
|
||||||
self._bottom_bar: BottomBar | str | Callable[[], Any] = BottomBar(self.columns, self.key_bindings, key_validator=self.is_key_available)
|
self._bottom_bar: BottomBar | str | Callable[[], Any] = BottomBar(
|
||||||
|
self.columns, self.key_bindings, key_validator=self.is_key_available
|
||||||
|
)
|
||||||
elif isinstance(bottom_bar, BottomBar):
|
elif isinstance(bottom_bar, BottomBar):
|
||||||
bottom_bar.key_validator = self.is_key_available
|
bottom_bar.key_validator = self.is_key_available
|
||||||
bottom_bar.key_bindings = self.key_bindings
|
bottom_bar.key_bindings = self.key_bindings
|
||||||
self._bottom_bar = bottom_bar
|
self._bottom_bar = bottom_bar
|
||||||
elif (isinstance(bottom_bar, str) or callable(bottom_bar)):
|
elif isinstance(bottom_bar, str) or callable(bottom_bar):
|
||||||
self._bottom_bar = bottom_bar
|
self._bottom_bar = bottom_bar
|
||||||
else:
|
else:
|
||||||
raise FalyxError("Bottom bar must be a string, callable, or BottomBar instance.")
|
raise FalyxError(
|
||||||
|
"Bottom bar must be a string, callable, or BottomBar instance."
|
||||||
|
)
|
||||||
self._invalidate_session_cache()
|
self._invalidate_session_cache()
|
||||||
|
|
||||||
def _get_bottom_bar_render(self) -> Callable[[], Any] | str | None:
|
def _get_bottom_bar_render(self) -> Callable[[], Any] | str | None:
|
||||||
|
@ -381,14 +404,14 @@ class Falyx:
|
||||||
"""Returns the prompt session for the menu."""
|
"""Returns the prompt session for the menu."""
|
||||||
if self._session is None:
|
if self._session is None:
|
||||||
self._session = PromptSession(
|
self._session = PromptSession(
|
||||||
message=self.prompt,
|
message=self.prompt,
|
||||||
multiline=False,
|
multiline=False,
|
||||||
completer=self._get_completer(),
|
completer=self._get_completer(),
|
||||||
reserve_space_for_menu=1,
|
reserve_space_for_menu=1,
|
||||||
validator=self._get_validator(),
|
validator=self._get_validator(),
|
||||||
bottom_toolbar=self._get_bottom_bar_render(),
|
bottom_toolbar=self._get_bottom_bar_render(),
|
||||||
key_bindings=self.key_bindings,
|
key_bindings=self.key_bindings,
|
||||||
)
|
)
|
||||||
return self._session
|
return self._session
|
||||||
|
|
||||||
def register_all_hooks(self, hook_type: HookType, hooks: Hook | list[Hook]) -> None:
|
def register_all_hooks(self, hook_type: HookType, hooks: Hook | list[Hook]) -> None:
|
||||||
|
@ -414,32 +437,58 @@ class Falyx:
|
||||||
|
|
||||||
def debug_hooks(self) -> None:
|
def debug_hooks(self) -> None:
|
||||||
"""Logs the names of all hooks registered for the menu and its commands."""
|
"""Logs the names of all hooks registered for the menu and its commands."""
|
||||||
|
|
||||||
def hook_names(hook_list):
|
def hook_names(hook_list):
|
||||||
return [hook.__name__ for hook in hook_list]
|
return [hook.__name__ for hook in hook_list]
|
||||||
|
|
||||||
logger.debug(f"Menu-level before hooks: {hook_names(self.hooks._hooks[HookType.BEFORE])}")
|
logger.debug(
|
||||||
logger.debug(f"Menu-level success hooks: {hook_names(self.hooks._hooks[HookType.ON_SUCCESS])}")
|
"Menu-level before hooks: "
|
||||||
logger.debug(f"Menu-level error hooks: {hook_names(self.hooks._hooks[HookType.ON_ERROR])}")
|
f"{hook_names(self.hooks._hooks[HookType.BEFORE])}"
|
||||||
logger.debug(f"Menu-level after hooks: {hook_names(self.hooks._hooks[HookType.AFTER])}")
|
)
|
||||||
logger.debug(f"Menu-level on_teardown hooks: {hook_names(self.hooks._hooks[HookType.ON_TEARDOWN])}")
|
logger.debug(
|
||||||
|
f"Menu-level success hooks: {hook_names(self.hooks._hooks[HookType.ON_SUCCESS])}"
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"Menu-level error hooks: {hook_names(self.hooks._hooks[HookType.ON_ERROR])}"
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"Menu-level after hooks: {hook_names(self.hooks._hooks[HookType.AFTER])}"
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"Menu-level on_teardown hooks: {hook_names(self.hooks._hooks[HookType.ON_TEARDOWN])}"
|
||||||
|
)
|
||||||
|
|
||||||
for key, command in self.commands.items():
|
for key, command in self.commands.items():
|
||||||
logger.debug(f"[Command '{key}'] before: {hook_names(command.hooks._hooks[HookType.BEFORE])}")
|
logger.debug(
|
||||||
logger.debug(f"[Command '{key}'] success: {hook_names(command.hooks._hooks[HookType.ON_SUCCESS])}")
|
f"[Command '{key}'] before: {hook_names(command.hooks._hooks[HookType.BEFORE])}"
|
||||||
logger.debug(f"[Command '{key}'] error: {hook_names(command.hooks._hooks[HookType.ON_ERROR])}")
|
)
|
||||||
logger.debug(f"[Command '{key}'] after: {hook_names(command.hooks._hooks[HookType.AFTER])}")
|
logger.debug(
|
||||||
logger.debug(f"[Command '{key}'] on_teardown: {hook_names(command.hooks._hooks[HookType.ON_TEARDOWN])}")
|
f"[Command '{key}'] success: {hook_names(command.hooks._hooks[HookType.ON_SUCCESS])}"
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"[Command '{key}'] error: {hook_names(command.hooks._hooks[HookType.ON_ERROR])}"
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"[Command '{key}'] after: {hook_names(command.hooks._hooks[HookType.AFTER])}"
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"[Command '{key}'] on_teardown: {hook_names(command.hooks._hooks[HookType.ON_TEARDOWN])}"
|
||||||
|
)
|
||||||
|
|
||||||
def is_key_available(self, key: str) -> bool:
|
def is_key_available(self, key: str) -> bool:
|
||||||
key = key.upper()
|
key = key.upper()
|
||||||
toggles = self._bottom_bar.toggle_keys if isinstance(self._bottom_bar, BottomBar) else []
|
toggles = (
|
||||||
|
self._bottom_bar.toggle_keys
|
||||||
|
if isinstance(self._bottom_bar, BottomBar)
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
conflicts = (
|
conflicts = (
|
||||||
key in self.commands,
|
key in self.commands,
|
||||||
key == self.exit_command.key.upper(),
|
key == self.exit_command.key.upper(),
|
||||||
self.history_command and key == self.history_command.key.upper(),
|
self.history_command and key == self.history_command.key.upper(),
|
||||||
self.help_command and key == self.help_command.key.upper(),
|
self.help_command and key == self.help_command.key.upper(),
|
||||||
key in toggles
|
key in toggles,
|
||||||
)
|
)
|
||||||
|
|
||||||
return not any(conflicts)
|
return not any(conflicts)
|
||||||
|
@ -447,7 +496,11 @@ class Falyx:
|
||||||
def _validate_command_key(self, key: str) -> None:
|
def _validate_command_key(self, key: str) -> None:
|
||||||
"""Validates the command key to ensure it is unique."""
|
"""Validates the command key to ensure it is unique."""
|
||||||
key = key.upper()
|
key = key.upper()
|
||||||
toggles = self._bottom_bar.toggle_keys if isinstance(self._bottom_bar, BottomBar) else []
|
toggles = (
|
||||||
|
self._bottom_bar.toggle_keys
|
||||||
|
if isinstance(self._bottom_bar, BottomBar)
|
||||||
|
else []
|
||||||
|
)
|
||||||
collisions = []
|
collisions = []
|
||||||
|
|
||||||
if key in self.commands:
|
if key in self.commands:
|
||||||
|
@ -462,7 +515,9 @@ class Falyx:
|
||||||
collisions.append("toggle")
|
collisions.append("toggle")
|
||||||
|
|
||||||
if collisions:
|
if collisions:
|
||||||
raise CommandAlreadyExistsError(f"Command key '{key}' conflicts with existing {', '.join(collisions)}.")
|
raise CommandAlreadyExistsError(
|
||||||
|
f"Command key '{key}' conflicts with existing {', '.join(collisions)}."
|
||||||
|
)
|
||||||
|
|
||||||
def update_exit_command(
|
def update_exit_command(
|
||||||
self,
|
self,
|
||||||
|
@ -486,7 +541,9 @@ class Falyx:
|
||||||
confirm_message=confirm_message,
|
confirm_message=confirm_message,
|
||||||
)
|
)
|
||||||
|
|
||||||
def add_submenu(self, key: str, description: str, submenu: "Falyx", color: str = OneColors.CYAN) -> None:
|
def add_submenu(
|
||||||
|
self, key: str, description: str, submenu: "Falyx", color: str = OneColors.CYAN
|
||||||
|
) -> None:
|
||||||
"""Adds a submenu to the menu."""
|
"""Adds a submenu to the menu."""
|
||||||
if not isinstance(submenu, Falyx):
|
if not isinstance(submenu, Falyx):
|
||||||
raise NotAFalyxError("submenu must be an instance of Falyx.")
|
raise NotAFalyxError("submenu must be an instance of Falyx.")
|
||||||
|
@ -581,10 +638,16 @@ class Falyx:
|
||||||
"""Returns the bottom row of the table for displaying additional commands."""
|
"""Returns the bottom row of the table for displaying additional commands."""
|
||||||
bottom_row = []
|
bottom_row = []
|
||||||
if self.history_command:
|
if self.history_command:
|
||||||
bottom_row.append(f"[{self.history_command.key}] [{self.history_command.color}]{self.history_command.description}")
|
bottom_row.append(
|
||||||
|
f"[{self.history_command.key}] [{self.history_command.color}]{self.history_command.description}"
|
||||||
|
)
|
||||||
if self.help_command:
|
if self.help_command:
|
||||||
bottom_row.append(f"[{self.help_command.key}] [{self.help_command.color}]{self.help_command.description}")
|
bottom_row.append(
|
||||||
bottom_row.append(f"[{self.exit_command.key}] [{self.exit_command.color}]{self.exit_command.description}")
|
f"[{self.help_command.key}] [{self.help_command.color}]{self.help_command.description}"
|
||||||
|
)
|
||||||
|
bottom_row.append(
|
||||||
|
f"[{self.exit_command.key}] [{self.exit_command.color}]{self.exit_command.description}"
|
||||||
|
)
|
||||||
return bottom_row
|
return bottom_row
|
||||||
|
|
||||||
def build_default_table(self) -> Table:
|
def build_default_table(self) -> Table:
|
||||||
|
@ -626,13 +689,17 @@ class Falyx:
|
||||||
fuzzy_matches = get_close_matches(choice, list(name_map.keys()), n=3, cutoff=0.7)
|
fuzzy_matches = get_close_matches(choice, list(name_map.keys()), n=3, cutoff=0.7)
|
||||||
if fuzzy_matches:
|
if fuzzy_matches:
|
||||||
if not from_validate:
|
if not from_validate:
|
||||||
self.console.print(f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'. Did you mean:[/] ")
|
self.console.print(
|
||||||
|
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'. Did you mean:[/] "
|
||||||
|
)
|
||||||
for match in fuzzy_matches:
|
for match in fuzzy_matches:
|
||||||
cmd = name_map[match]
|
cmd = name_map[match]
|
||||||
self.console.print(f" • [bold]{match}[/] → {cmd.description}")
|
self.console.print(f" • [bold]{match}[/] → {cmd.description}")
|
||||||
else:
|
else:
|
||||||
if not from_validate:
|
if not from_validate:
|
||||||
self.console.print(f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'[/]")
|
self.console.print(
|
||||||
|
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'[/]"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _should_run_action(self, selected_command: Command) -> bool:
|
async def _should_run_action(self, selected_command: Command) -> bool:
|
||||||
|
@ -642,9 +709,11 @@ class Falyx:
|
||||||
if self.cli_args and getattr(self.cli_args, "skip_confirm", False):
|
if self.cli_args and getattr(self.cli_args, "skip_confirm", False):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if (self._always_confirm or
|
if (
|
||||||
selected_command.confirm or
|
self._always_confirm
|
||||||
self.cli_args and getattr(self.cli_args, "force_confirm", False)
|
or selected_command.confirm
|
||||||
|
or self.cli_args
|
||||||
|
and getattr(self.cli_args, "force_confirm", False)
|
||||||
):
|
):
|
||||||
if selected_command.preview_before_confirm:
|
if selected_command.preview_before_confirm:
|
||||||
await selected_command.preview()
|
await selected_command.preview()
|
||||||
|
@ -676,16 +745,20 @@ class Falyx:
|
||||||
):
|
):
|
||||||
return await command()
|
return await command()
|
||||||
|
|
||||||
async def _handle_action_error(self, selected_command: Command, error: Exception) -> bool:
|
async def _handle_action_error(
|
||||||
|
self, selected_command: Command, error: Exception
|
||||||
|
) -> bool:
|
||||||
"""Handles errors that occur during the action of the selected command."""
|
"""Handles errors that occur during the action of the selected command."""
|
||||||
logger.exception(f"Error executing '{selected_command.description}': {error}")
|
logger.exception(f"Error executing '{selected_command.description}': {error}")
|
||||||
self.console.print(f"[{OneColors.DARK_RED}]An error occurred while executing "
|
self.console.print(
|
||||||
f"{selected_command.description}:[/] {error}")
|
f"[{OneColors.DARK_RED}]An error occurred while executing "
|
||||||
|
f"{selected_command.description}:[/] {error}"
|
||||||
|
)
|
||||||
if self.confirm_on_error and not self._never_confirm:
|
if self.confirm_on_error and not self._never_confirm:
|
||||||
return await async_confirm("An error occurred. Do you wish to continue?")
|
return await async_confirm("An error occurred. Do you wish to continue?")
|
||||||
if self._never_confirm:
|
if self._never_confirm:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def process_command(self) -> bool:
|
async def process_command(self) -> bool:
|
||||||
"""Processes the action of the selected command."""
|
"""Processes the action of the selected command."""
|
||||||
|
@ -700,8 +773,7 @@ class Falyx:
|
||||||
self.console.print(
|
self.console.print(
|
||||||
f"[{OneColors.LIGHT_YELLOW}]⚠️ Command '{selected_command.key}' requires input "
|
f"[{OneColors.LIGHT_YELLOW}]⚠️ Command '{selected_command.key}' requires input "
|
||||||
f"and must be run via [{OneColors.MAGENTA}]'{program} run'[{OneColors.LIGHT_YELLOW}] "
|
f"and must be run via [{OneColors.MAGENTA}]'{program} run'[{OneColors.LIGHT_YELLOW}] "
|
||||||
"with proper piping or arguments.[/]"
|
"with proper piping or arguments.[/]"
|
||||||
|
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@ -730,7 +802,9 @@ class Falyx:
|
||||||
context.exception = error
|
context.exception = error
|
||||||
await self.hooks.trigger(HookType.ON_ERROR, context)
|
await self.hooks.trigger(HookType.ON_ERROR, context)
|
||||||
if not context.exception:
|
if not context.exception:
|
||||||
logger.info(f"✅ Recovery hook handled error for '{selected_command.description}'")
|
logger.info(
|
||||||
|
f"✅ Recovery hook handled error for '{selected_command.description}'"
|
||||||
|
)
|
||||||
context.result = result
|
context.result = result
|
||||||
else:
|
else:
|
||||||
return await self._handle_action_error(selected_command, error)
|
return await self._handle_action_error(selected_command, error)
|
||||||
|
@ -753,7 +827,9 @@ class Falyx:
|
||||||
logger.info(f"[Headless] 🚀 Running: '{selected_command.description}'")
|
logger.info(f"[Headless] 🚀 Running: '{selected_command.description}'")
|
||||||
|
|
||||||
if not await self._should_run_action(selected_command):
|
if not await self._should_run_action(selected_command):
|
||||||
raise FalyxError(f"[Headless] '{selected_command.description}' cancelled by confirmation.")
|
raise FalyxError(
|
||||||
|
f"[Headless] '{selected_command.description}' cancelled by confirmation."
|
||||||
|
)
|
||||||
|
|
||||||
context = self._create_context(selected_command)
|
context = self._create_context(selected_command)
|
||||||
context.start_timer()
|
context.start_timer()
|
||||||
|
@ -769,14 +845,20 @@ class Falyx:
|
||||||
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||||
logger.info(f"[Headless] ✅ '{selected_command.description}' complete.")
|
logger.info(f"[Headless] ✅ '{selected_command.description}' complete.")
|
||||||
except (KeyboardInterrupt, EOFError):
|
except (KeyboardInterrupt, EOFError):
|
||||||
raise FalyxError(f"[Headless] ⚠️ '{selected_command.description}' interrupted by user.")
|
raise FalyxError(
|
||||||
|
f"[Headless] ⚠️ '{selected_command.description}' interrupted by user."
|
||||||
|
)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
context.exception = error
|
context.exception = error
|
||||||
await self.hooks.trigger(HookType.ON_ERROR, context)
|
await self.hooks.trigger(HookType.ON_ERROR, context)
|
||||||
if not context.exception:
|
if not context.exception:
|
||||||
logger.info(f"[Headless] ✅ Recovery hook handled error for '{selected_command.description}'")
|
logger.info(
|
||||||
|
f"[Headless] ✅ Recovery hook handled error for '{selected_command.description}'"
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
raise FalyxError(f"[Headless] ❌ '{selected_command.description}' failed.") from error
|
raise FalyxError(
|
||||||
|
f"[Headless] ❌ '{selected_command.description}' failed."
|
||||||
|
) from error
|
||||||
finally:
|
finally:
|
||||||
context.stop_timer()
|
context.stop_timer()
|
||||||
await self.hooks.trigger(HookType.AFTER, context)
|
await self.hooks.trigger(HookType.AFTER, context)
|
||||||
|
@ -787,7 +869,11 @@ class Falyx:
|
||||||
def _set_retry_policy(self, selected_command: Command) -> None:
|
def _set_retry_policy(self, selected_command: Command) -> None:
|
||||||
"""Sets the retry policy for the command based on CLI arguments."""
|
"""Sets the retry policy for the command based on CLI arguments."""
|
||||||
assert isinstance(self.cli_args, Namespace), "CLI arguments must be provided."
|
assert isinstance(self.cli_args, Namespace), "CLI arguments must be provided."
|
||||||
if self.cli_args.retries or self.cli_args.retry_delay or self.cli_args.retry_backoff:
|
if (
|
||||||
|
self.cli_args.retries
|
||||||
|
or self.cli_args.retry_delay
|
||||||
|
or self.cli_args.retry_backoff
|
||||||
|
):
|
||||||
selected_command.retry_policy.enabled = True
|
selected_command.retry_policy.enabled = True
|
||||||
if self.cli_args.retries:
|
if self.cli_args.retries:
|
||||||
selected_command.retry_policy.max_retries = self.cli_args.retries
|
selected_command.retry_policy.max_retries = self.cli_args.retries
|
||||||
|
@ -798,7 +884,9 @@ class Falyx:
|
||||||
if isinstance(selected_command.action, Action):
|
if isinstance(selected_command.action, Action):
|
||||||
selected_command.action.set_retry_policy(selected_command.retry_policy)
|
selected_command.action.set_retry_policy(selected_command.retry_policy)
|
||||||
else:
|
else:
|
||||||
logger.warning(f"[Command:{selected_command.key}] Retry requested, but action is not an Action instance.")
|
logger.warning(
|
||||||
|
f"[Command:{selected_command.key}] Retry requested, but action is not an Action instance."
|
||||||
|
)
|
||||||
|
|
||||||
def print_message(self, message: str | Markdown | dict[str, Any]) -> None:
|
def print_message(self, message: str | Markdown | dict[str, Any]) -> None:
|
||||||
"""Prints a message to the console."""
|
"""Prints a message to the console."""
|
||||||
|
@ -821,7 +909,10 @@ class Falyx:
|
||||||
if self.welcome_message:
|
if self.welcome_message:
|
||||||
self.print_message(self.welcome_message)
|
self.print_message(self.welcome_message)
|
||||||
while True:
|
while True:
|
||||||
self.console.print(self.table, justify="center")
|
if callable(self.render_menu):
|
||||||
|
self.render_menu(self)
|
||||||
|
elif isinstance(self.render_menu, str):
|
||||||
|
self.console.print(self.table, justify="center")
|
||||||
try:
|
try:
|
||||||
task = asyncio.create_task(self.process_command())
|
task = asyncio.create_task(self.process_command())
|
||||||
should_continue = await task
|
should_continue = await task
|
||||||
|
@ -858,16 +949,22 @@ class Falyx:
|
||||||
if self.cli_args.command == "preview":
|
if self.cli_args.command == "preview":
|
||||||
command = self.get_command(self.cli_args.name)
|
command = self.get_command(self.cli_args.name)
|
||||||
if not command:
|
if not command:
|
||||||
self.console.print(f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found.[/]")
|
self.console.print(
|
||||||
|
f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found.[/]"
|
||||||
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
self.console.print(f"Preview of command '{command.key}': {command.description}")
|
self.console.print(
|
||||||
|
f"Preview of command '{command.key}': {command.description}"
|
||||||
|
)
|
||||||
await command.preview()
|
await command.preview()
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
if self.cli_args.command == "run":
|
if self.cli_args.command == "run":
|
||||||
command = self.get_command(self.cli_args.name)
|
command = self.get_command(self.cli_args.name)
|
||||||
if not command:
|
if not command:
|
||||||
self.console.print(f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found.[/]")
|
self.console.print(
|
||||||
|
f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found.[/]"
|
||||||
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
self._set_retry_policy(command)
|
self._set_retry_policy(command)
|
||||||
try:
|
try:
|
||||||
|
@ -879,14 +976,19 @@ class Falyx:
|
||||||
|
|
||||||
if self.cli_args.command == "run-all":
|
if self.cli_args.command == "run-all":
|
||||||
matching = [
|
matching = [
|
||||||
cmd for cmd in self.commands.values()
|
cmd
|
||||||
|
for cmd in self.commands.values()
|
||||||
if self.cli_args.tag.lower() in (tag.lower() for tag in cmd.tags)
|
if self.cli_args.tag.lower() in (tag.lower() for tag in cmd.tags)
|
||||||
]
|
]
|
||||||
if not matching:
|
if not matching:
|
||||||
self.console.print(f"[{OneColors.LIGHT_YELLOW}]⚠️ No commands found with tag: '{self.cli_args.tag}'[/]")
|
self.console.print(
|
||||||
|
f"[{OneColors.LIGHT_YELLOW}]⚠️ No commands found with tag: '{self.cli_args.tag}'[/]"
|
||||||
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
self.console.print(f"[{OneColors.CYAN_b}]🚀 Running all commands with tag:[/] {self.cli_args.tag}")
|
self.console.print(
|
||||||
|
f"[{OneColors.CYAN_b}]🚀 Running all commands with tag:[/] {self.cli_args.tag}"
|
||||||
|
)
|
||||||
for cmd in matching:
|
for cmd in matching:
|
||||||
self._set_retry_policy(cmd)
|
self._set_retry_policy(cmd)
|
||||||
await self.headless(cmd.key)
|
await self.headless(cmd.key)
|
||||||
|
|
|
@ -10,13 +10,13 @@ from falyx.context import ExecutionContext
|
||||||
from falyx.utils import logger
|
from falyx.utils import logger
|
||||||
|
|
||||||
Hook = Union[
|
Hook = Union[
|
||||||
Callable[[ExecutionContext], None],
|
Callable[[ExecutionContext], None], Callable[[ExecutionContext], Awaitable[None]]
|
||||||
Callable[[ExecutionContext], Awaitable[None]]
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class HookType(Enum):
|
class HookType(Enum):
|
||||||
"""Enum for hook types to categorize the hooks."""
|
"""Enum for hook types to categorize the hooks."""
|
||||||
|
|
||||||
BEFORE = "before"
|
BEFORE = "before"
|
||||||
ON_SUCCESS = "on_success"
|
ON_SUCCESS = "on_success"
|
||||||
ON_ERROR = "on_error"
|
ON_ERROR = "on_error"
|
||||||
|
@ -61,10 +61,13 @@ class HookManager:
|
||||||
else:
|
else:
|
||||||
hook(context)
|
hook(context)
|
||||||
except Exception as hook_error:
|
except Exception as hook_error:
|
||||||
logger.warning(f"⚠️ Hook '{hook.__name__}' raised an exception during '{hook_type}'"
|
logger.warning(
|
||||||
f" for '{context.name}': {hook_error}")
|
f"⚠️ Hook '{hook.__name__}' raised an exception during '{hook_type}'"
|
||||||
|
f" for '{context.name}': {hook_error}"
|
||||||
|
)
|
||||||
|
|
||||||
if hook_type == HookType.ON_ERROR:
|
if hook_type == HookType.ON_ERROR:
|
||||||
assert isinstance(context.exception, Exception), "Context exception should be set for ON_ERROR hook"
|
assert isinstance(
|
||||||
|
context.exception, Exception
|
||||||
|
), "Context exception should be set for ON_ERROR hook"
|
||||||
raise context.exception from hook_error
|
raise context.exception from hook_error
|
||||||
|
|
||||||
|
|
|
@ -25,9 +25,13 @@ class ResultReporter:
|
||||||
raise TypeError("formatter must be callable")
|
raise TypeError("formatter must be callable")
|
||||||
if context.result is not None:
|
if context.result is not None:
|
||||||
result_text = self.formatter(context.result)
|
result_text = self.formatter(context.result)
|
||||||
duration = f"{context.duration:.3f}s" if context.duration is not None else "n/a"
|
duration = (
|
||||||
context.console.print(f"[{OneColors.GREEN}]✅ '{context.name}' "
|
f"{context.duration:.3f}s" if context.duration is not None else "n/a"
|
||||||
f"completed:[/] {result_text} in {duration}.")
|
)
|
||||||
|
context.console.print(
|
||||||
|
f"[{OneColors.GREEN}]✅ '{context.name}' "
|
||||||
|
f"completed:[/] {result_text} in {duration}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CircuitBreaker:
|
class CircuitBreaker:
|
||||||
|
@ -41,7 +45,9 @@ class CircuitBreaker:
|
||||||
name = context.name
|
name = context.name
|
||||||
if self.open_until:
|
if self.open_until:
|
||||||
if time.time() < self.open_until:
|
if time.time() < self.open_until:
|
||||||
raise CircuitBreakerOpen(f"🔴 Circuit open for '{name}' until {time.ctime(self.open_until)}.")
|
raise CircuitBreakerOpen(
|
||||||
|
f"🔴 Circuit open for '{name}' until {time.ctime(self.open_until)}."
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.info(f"🟢 Circuit closed again for '{name}'.")
|
logger.info(f"🟢 Circuit closed again for '{name}'.")
|
||||||
self.failures = 0
|
self.failures = 0
|
||||||
|
@ -50,10 +56,14 @@ class CircuitBreaker:
|
||||||
def error_hook(self, context: ExecutionContext):
|
def error_hook(self, context: ExecutionContext):
|
||||||
name = context.name
|
name = context.name
|
||||||
self.failures += 1
|
self.failures += 1
|
||||||
logger.warning(f"⚠️ CircuitBreaker: '{name}' failure {self.failures}/{self.max_failures}.")
|
logger.warning(
|
||||||
|
f"⚠️ CircuitBreaker: '{name}' failure {self.failures}/{self.max_failures}."
|
||||||
|
)
|
||||||
if self.failures >= self.max_failures:
|
if self.failures >= self.max_failures:
|
||||||
self.open_until = time.time() + self.reset_timeout
|
self.open_until = time.time() + self.reset_timeout
|
||||||
logger.error(f"🔴 Circuit opened for '{name}' until {time.ctime(self.open_until)}.")
|
logger.error(
|
||||||
|
f"🔴 Circuit opened for '{name}' until {time.ctime(self.open_until)}."
|
||||||
|
)
|
||||||
|
|
||||||
def after_hook(self, context: ExecutionContext):
|
def after_hook(self, context: ExecutionContext):
|
||||||
self.failures = 0
|
self.failures = 0
|
||||||
|
|
|
@ -59,6 +59,7 @@ class HTTPAction(Action):
|
||||||
retry (bool): Enable retry logic.
|
retry (bool): Enable retry logic.
|
||||||
retry_policy (RetryPolicy): Retry settings.
|
retry_policy (RetryPolicy): Retry settings.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
"""io_action.py
|
"""io_action.py
|
||||||
BaseIOAction: A base class for stream- or buffer-based IO-driven Actions.
|
BaseIOAction: A base class for stream- or buffer-based IO-driven Actions.
|
||||||
|
|
||||||
This module defines `BaseIOAction`, a specialized variant of `BaseAction`
|
This module defines `BaseIOAction`, a specialized variant of `BaseAction`
|
||||||
that interacts with standard input and output, enabling command-line pipelines,
|
that interacts with standard input and output, enabling command-line pipelines,
|
||||||
text filters, and stream processing tasks.
|
text filters, and stream processing tasks.
|
||||||
|
|
||||||
|
@ -58,6 +58,7 @@ class BaseIOAction(BaseAction):
|
||||||
mode (str): Either "buffered" or "stream". Controls input behavior.
|
mode (str): Either "buffered" or "stream". Controls input behavior.
|
||||||
inject_last_result (bool): Whether to inject shared context input.
|
inject_last_result (bool): Whether to inject shared context input.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
|
@ -94,7 +95,9 @@ class BaseIOAction(BaseAction):
|
||||||
if self.inject_last_result and self.shared_context:
|
if self.inject_last_result and self.shared_context:
|
||||||
return self.shared_context.last_result()
|
return self.shared_context.last_result()
|
||||||
|
|
||||||
logger.debug("[%s] No input provided and no last result found for injection.", self.name)
|
logger.debug(
|
||||||
|
"[%s] No input provided and no last result found for injection.", self.name
|
||||||
|
)
|
||||||
raise FalyxError("No input provided and no last result to inject.")
|
raise FalyxError("No input provided and no last result to inject.")
|
||||||
|
|
||||||
async def __call__(self, *args, **kwargs):
|
async def __call__(self, *args, **kwargs):
|
||||||
|
@ -137,7 +140,6 @@ class BaseIOAction(BaseAction):
|
||||||
return await asyncio.to_thread(sys.stdin.read)
|
return await asyncio.to_thread(sys.stdin.read)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
async def _read_stdin_stream(self) -> Any:
|
async def _read_stdin_stream(self) -> Any:
|
||||||
"""Returns a generator that yields lines from stdin in a background thread."""
|
"""Returns a generator that yields lines from stdin in a background thread."""
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
|
@ -176,7 +178,9 @@ class BaseIOAction(BaseAction):
|
||||||
class UppercaseIO(BaseIOAction):
|
class UppercaseIO(BaseIOAction):
|
||||||
def from_input(self, raw: str | bytes) -> str:
|
def from_input(self, raw: str | bytes) -> str:
|
||||||
if not isinstance(raw, (str, bytes)):
|
if not isinstance(raw, (str, bytes)):
|
||||||
raise TypeError(f"{self.name} expected str or bytes input, got {type(raw).__name__}")
|
raise TypeError(
|
||||||
|
f"{self.name} expected str or bytes input, got {type(raw).__name__}"
|
||||||
|
)
|
||||||
return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip()
|
return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip()
|
||||||
|
|
||||||
async def _run(self, parsed_input: str, *args, **kwargs) -> str:
|
async def _run(self, parsed_input: str, *args, **kwargs) -> str:
|
||||||
|
@ -213,21 +217,22 @@ class ShellAction(BaseIOAction):
|
||||||
command_template (str): Shell command to execute. Must include `{}` to include input.
|
command_template (str): Shell command to execute. Must include `{}` to include input.
|
||||||
If no placeholder is present, the input is not included.
|
If no placeholder is present, the input is not included.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, name: str, command_template: str, **kwargs):
|
def __init__(self, name: str, command_template: str, **kwargs):
|
||||||
super().__init__(name=name, **kwargs)
|
super().__init__(name=name, **kwargs)
|
||||||
self.command_template = command_template
|
self.command_template = command_template
|
||||||
|
|
||||||
def from_input(self, raw: str | bytes) -> str:
|
def from_input(self, raw: str | bytes) -> str:
|
||||||
if not isinstance(raw, (str, bytes)):
|
if not isinstance(raw, (str, bytes)):
|
||||||
raise TypeError(f"{self.name} expected str or bytes input, got {type(raw).__name__}")
|
raise TypeError(
|
||||||
|
f"{self.name} expected str or bytes input, got {type(raw).__name__}"
|
||||||
|
)
|
||||||
return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip()
|
return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip()
|
||||||
|
|
||||||
async def _run(self, parsed_input: str) -> str:
|
async def _run(self, parsed_input: str) -> str:
|
||||||
# Replace placeholder in template, or use raw input as full command
|
# Replace placeholder in template, or use raw input as full command
|
||||||
command = self.command_template.format(parsed_input)
|
command = self.command_template.format(parsed_input)
|
||||||
result = subprocess.run(
|
result = subprocess.run(command, shell=True, text=True, capture_output=True)
|
||||||
command, shell=True, text=True, capture_output=True
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise RuntimeError(result.stderr.strip())
|
raise RuntimeError(result.stderr.strip())
|
||||||
return result.stdout.strip()
|
return result.stdout.strip()
|
||||||
|
@ -245,7 +250,10 @@ class ShellAction(BaseIOAction):
|
||||||
console.print(Tree("".join(label)))
|
console.print(Tree("".join(label)))
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"ShellAction(name={self.name!r}, command_template={self.command_template!r})"
|
return (
|
||||||
|
f"ShellAction(name={self.name!r}, command_template={self.command_template!r})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GrepAction(BaseIOAction):
|
class GrepAction(BaseIOAction):
|
||||||
def __init__(self, name: str, pattern: str, **kwargs):
|
def __init__(self, name: str, pattern: str, **kwargs):
|
||||||
|
@ -254,13 +262,19 @@ class GrepAction(BaseIOAction):
|
||||||
|
|
||||||
def from_input(self, raw: str | bytes) -> str:
|
def from_input(self, raw: str | bytes) -> str:
|
||||||
if not isinstance(raw, (str, bytes)):
|
if not isinstance(raw, (str, bytes)):
|
||||||
raise TypeError(f"{self.name} expected str or bytes input, got {type(raw).__name__}")
|
raise TypeError(
|
||||||
|
f"{self.name} expected str or bytes input, got {type(raw).__name__}"
|
||||||
|
)
|
||||||
return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip()
|
return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip()
|
||||||
|
|
||||||
async def _run(self, parsed_input: str) -> str:
|
async def _run(self, parsed_input: str) -> str:
|
||||||
command = ["grep", "-n", self.pattern]
|
command = ["grep", "-n", self.pattern]
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
|
command,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
)
|
)
|
||||||
stdout, stderr = process.communicate(input=parsed_input)
|
stdout, stderr = process.communicate(input=parsed_input)
|
||||||
if process.returncode == 1:
|
if process.returncode == 1:
|
||||||
|
@ -271,4 +285,3 @@ class GrepAction(BaseIOAction):
|
||||||
|
|
||||||
def to_output(self, result: str) -> str:
|
def to_output(self, result: str) -> str:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
|
@ -26,9 +26,7 @@ class OptionsManager:
|
||||||
"""Get the value of an option."""
|
"""Get the value of an option."""
|
||||||
return getattr(self.options[namespace_name], option_name, default)
|
return getattr(self.options[namespace_name], option_name, default)
|
||||||
|
|
||||||
def set(
|
def set(self, option_name: str, value: Any, namespace_name: str = "cli_args") -> None:
|
||||||
self, option_name: str, value: Any, namespace_name: str = "cli_args"
|
|
||||||
) -> None:
|
|
||||||
"""Set the value of an option."""
|
"""Set the value of an option."""
|
||||||
setattr(self.options[namespace_name], option_name, value)
|
setattr(self.options[namespace_name], option_name, value)
|
||||||
|
|
||||||
|
|
113
falyx/parsers.py
113
falyx/parsers.py
|
@ -10,6 +10,7 @@ from typing import Any, Sequence
|
||||||
@dataclass
|
@dataclass
|
||||||
class FalyxParsers:
|
class FalyxParsers:
|
||||||
"""Defines the argument parsers for the Falyx CLI."""
|
"""Defines the argument parsers for the Falyx CLI."""
|
||||||
|
|
||||||
root: ArgumentParser
|
root: ArgumentParser
|
||||||
run: ArgumentParser
|
run: ArgumentParser
|
||||||
run_all: ArgumentParser
|
run_all: ArgumentParser
|
||||||
|
@ -31,20 +32,20 @@ class FalyxParsers:
|
||||||
|
|
||||||
|
|
||||||
def get_arg_parsers(
|
def get_arg_parsers(
|
||||||
prog: str |None = "falyx",
|
prog: str | None = "falyx",
|
||||||
usage: str | None = None,
|
usage: str | None = None,
|
||||||
description: str | None = "Falyx CLI - Run structured async command workflows.",
|
description: str | None = "Falyx CLI - Run structured async command workflows.",
|
||||||
epilog: str | None = None,
|
epilog: str | None = None,
|
||||||
parents: Sequence[ArgumentParser] = [],
|
parents: Sequence[ArgumentParser] = [],
|
||||||
formatter_class: HelpFormatter = HelpFormatter,
|
formatter_class: HelpFormatter = HelpFormatter,
|
||||||
prefix_chars: str = "-",
|
prefix_chars: str = "-",
|
||||||
fromfile_prefix_chars: str | None = None,
|
fromfile_prefix_chars: str | None = None,
|
||||||
argument_default: Any = None,
|
argument_default: Any = None,
|
||||||
conflict_handler: str = "error",
|
conflict_handler: str = "error",
|
||||||
add_help: bool = True,
|
add_help: bool = True,
|
||||||
allow_abbrev: bool = True,
|
allow_abbrev: bool = True,
|
||||||
exit_on_error: bool = True,
|
exit_on_error: bool = True,
|
||||||
) -> FalyxParsers:
|
) -> FalyxParsers:
|
||||||
"""Returns the argument parser for the CLI."""
|
"""Returns the argument parser for the CLI."""
|
||||||
parser = ArgumentParser(
|
parser = ArgumentParser(
|
||||||
prog=prog,
|
prog=prog,
|
||||||
|
@ -61,33 +62,87 @@ def get_arg_parsers(
|
||||||
allow_abbrev=allow_abbrev,
|
allow_abbrev=allow_abbrev,
|
||||||
exit_on_error=exit_on_error,
|
exit_on_error=exit_on_error,
|
||||||
)
|
)
|
||||||
parser.add_argument("-v", "--verbose", action="store_true", help="Enable debug logging for Falyx.")
|
parser.add_argument(
|
||||||
parser.add_argument("--debug-hooks", action="store_true", help="Enable default lifecycle debug logging")
|
"-v", "--verbose", action="store_true", help="Enable debug logging for Falyx."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--debug-hooks",
|
||||||
|
action="store_true",
|
||||||
|
help="Enable default lifecycle debug logging",
|
||||||
|
)
|
||||||
parser.add_argument("--version", action="store_true", help="Show Falyx version")
|
parser.add_argument("--version", action="store_true", help="Show Falyx version")
|
||||||
subparsers = parser.add_subparsers(dest="command")
|
subparsers = parser.add_subparsers(dest="command")
|
||||||
|
|
||||||
run_parser = subparsers.add_parser("run", help="Run a specific command")
|
run_parser = subparsers.add_parser("run", help="Run a specific command")
|
||||||
run_parser.add_argument("name", help="Key, alias, or description of the command")
|
run_parser.add_argument("name", help="Key, alias, or description of the command")
|
||||||
run_parser.add_argument("--retries", type=int, help="Number of retries on failure", default=0)
|
run_parser.add_argument(
|
||||||
run_parser.add_argument("--retry-delay", type=float, help="Initial delay between retries in (seconds)", default=0)
|
"--retries", type=int, help="Number of retries on failure", default=0
|
||||||
run_parser.add_argument("--retry-backoff", type=float, help="Backoff factor for retries", 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 = 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(
|
||||||
run_group.add_argument("-s", "--skip-confirm", dest="skip_confirm", action="store_true", help="Skip confirmation prompts")
|
"-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_all_parser = subparsers.add_parser("run-all", help="Run all commands with a given tag")
|
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("-t", "--tag", required=True, help="Tag to match")
|
||||||
run_all_parser.add_argument("--retries", type=int, help="Number of retries on failure", default=0)
|
run_all_parser.add_argument(
|
||||||
run_all_parser.add_argument("--retry-delay", type=float, help="Initial delay between retries in (seconds)", default=0)
|
"--retries", type=int, help="Number of retries on failure", default=0
|
||||||
run_all_parser.add_argument("--retry-backoff", type=float, help="Backoff factor for retries", 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 = 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(
|
||||||
run_all_group.add_argument("-s", "--skip-confirm", dest="skip_confirm", action="store_true", help="Skip confirmation prompts")
|
"-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 = subparsers.add_parser(
|
||||||
|
"preview", help="Preview a command without running it"
|
||||||
|
)
|
||||||
preview_parser.add_argument("name", help="Key, alias, or description of the command")
|
preview_parser.add_argument("name", help="Key, alias, or description of the command")
|
||||||
|
|
||||||
list_parser = subparsers.add_parser("list", help="List all available commands with tags")
|
list_parser = subparsers.add_parser(
|
||||||
|
"list", help="List all available commands with tags"
|
||||||
|
)
|
||||||
|
|
||||||
version_parser = subparsers.add_parser("version", help="Show the Falyx version")
|
version_parser = subparsers.add_parser("version", help="Show the Falyx version")
|
||||||
|
|
||||||
|
|
|
@ -34,15 +34,15 @@ class RetryPolicy(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class RetryHandler:
|
class RetryHandler:
|
||||||
def __init__(self, policy: RetryPolicy=RetryPolicy()):
|
def __init__(self, policy: RetryPolicy = RetryPolicy()):
|
||||||
self.policy = policy
|
self.policy = policy
|
||||||
|
|
||||||
def enable_policy(
|
def enable_policy(
|
||||||
self,
|
self,
|
||||||
max_retries: int=3,
|
max_retries: int = 3,
|
||||||
delay: float=1.0,
|
delay: float = 1.0,
|
||||||
backoff: float=2.0,
|
backoff: float = 2.0,
|
||||||
jitter: float=0.0,
|
jitter: float = 0.0,
|
||||||
):
|
):
|
||||||
self.policy.enabled = True
|
self.policy.enabled = True
|
||||||
self.policy.max_retries = max_retries
|
self.policy.max_retries = max_retries
|
||||||
|
@ -53,6 +53,7 @@ class RetryHandler:
|
||||||
|
|
||||||
async def retry_on_error(self, context: ExecutionContext):
|
async def retry_on_error(self, context: ExecutionContext):
|
||||||
from falyx.action import Action
|
from falyx.action import Action
|
||||||
|
|
||||||
name = context.name
|
name = context.name
|
||||||
error = context.exception
|
error = context.exception
|
||||||
target = context.action
|
target = context.action
|
||||||
|
@ -66,7 +67,9 @@ class RetryHandler:
|
||||||
return
|
return
|
||||||
|
|
||||||
if not isinstance(target, Action):
|
if not isinstance(target, Action):
|
||||||
logger.warning(f"[{name}] ❌ RetryHandler only supports only supports Action objects.")
|
logger.warning(
|
||||||
|
f"[{name}] ❌ RetryHandler only supports only supports Action objects."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not getattr(target, "is_retryable", False):
|
if not getattr(target, "is_retryable", False):
|
||||||
|
|
|
@ -17,6 +17,7 @@ Example dynamic usage:
|
||||||
console.print("Hello!", style=NordColors.NORD12bu)
|
console.print("Hello!", style=NordColors.NORD12bu)
|
||||||
# => Renders "Hello!" in #D08770 (Nord12) plus bold and underline styles
|
# => Renders "Hello!" in #D08770 (Nord12) plus bold and underline styles
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from difflib import get_close_matches
|
from difflib import get_close_matches
|
||||||
|
|
||||||
|
@ -82,14 +83,17 @@ class ColorsMeta(type):
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
error_msg = [f"'{cls.__name__}' has no color named '{base}'."]
|
error_msg = [f"'{cls.__name__}' has no color named '{base}'."]
|
||||||
valid_bases = [
|
valid_bases = [
|
||||||
key for key, val in cls.__dict__.items() if isinstance(val, str) and
|
key
|
||||||
not key.startswith("__")
|
for key, val in cls.__dict__.items()
|
||||||
|
if isinstance(val, str) and not key.startswith("__")
|
||||||
]
|
]
|
||||||
suggestions = get_close_matches(base, valid_bases, n=1, cutoff=0.5)
|
suggestions = get_close_matches(base, valid_bases, n=1, cutoff=0.5)
|
||||||
if suggestions:
|
if suggestions:
|
||||||
error_msg.append(f"Did you mean '{suggestions[0]}'?")
|
error_msg.append(f"Did you mean '{suggestions[0]}'?")
|
||||||
if valid_bases:
|
if valid_bases:
|
||||||
error_msg.append(f"Valid base color names include: {', '.join(valid_bases)}")
|
error_msg.append(
|
||||||
|
f"Valid base color names include: {', '.join(valid_bases)}"
|
||||||
|
)
|
||||||
raise AttributeError(" ".join(error_msg)) from None
|
raise AttributeError(" ".join(error_msg)) from None
|
||||||
|
|
||||||
if not isinstance(color_value, str):
|
if not isinstance(color_value, str):
|
||||||
|
@ -105,7 +109,9 @@ class ColorsMeta(type):
|
||||||
if mapped_style:
|
if mapped_style:
|
||||||
styles.append(mapped_style)
|
styles.append(mapped_style)
|
||||||
else:
|
else:
|
||||||
raise AttributeError(f"Unknown style flag '{letter}' in attribute '{name}'")
|
raise AttributeError(
|
||||||
|
f"Unknown style flag '{letter}' in attribute '{name}'"
|
||||||
|
)
|
||||||
|
|
||||||
order = {"b": 1, "i": 2, "u": 3, "d": 4, "r": 5, "s": 6}
|
order = {"b": 1, "i": 2, "u": 3, "d": 4, "r": 5, "s": 6}
|
||||||
styles_sorted = sorted(styles, key=lambda s: order[s[0]])
|
styles_sorted = sorted(styles, key=lambda s: order[s[0]])
|
||||||
|
@ -133,7 +139,6 @@ class OneColors(metaclass=ColorsMeta):
|
||||||
BLUE = "#61AFEF"
|
BLUE = "#61AFEF"
|
||||||
MAGENTA = "#C678DD"
|
MAGENTA = "#C678DD"
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def as_dict(cls):
|
def as_dict(cls):
|
||||||
"""
|
"""
|
||||||
|
@ -143,10 +148,10 @@ class OneColors(metaclass=ColorsMeta):
|
||||||
return {
|
return {
|
||||||
attr: getattr(cls, attr)
|
attr: getattr(cls, attr)
|
||||||
for attr in dir(cls)
|
for attr in dir(cls)
|
||||||
if not callable(getattr(cls, attr)) and
|
if not callable(getattr(cls, attr)) and not attr.startswith("__")
|
||||||
not attr.startswith("__")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class NordColors(metaclass=ColorsMeta):
|
class NordColors(metaclass=ColorsMeta):
|
||||||
"""
|
"""
|
||||||
Defines the Nord color palette as class attributes.
|
Defines the Nord color palette as class attributes.
|
||||||
|
@ -215,19 +220,19 @@ class NordColors(metaclass=ColorsMeta):
|
||||||
return {
|
return {
|
||||||
attr: getattr(cls, attr)
|
attr: getattr(cls, attr)
|
||||||
for attr in dir(cls)
|
for attr in dir(cls)
|
||||||
if attr.startswith("NORD") and
|
if attr.startswith("NORD") and not callable(getattr(cls, attr))
|
||||||
not callable(getattr(cls, attr))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@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", "__")
|
||||||
alias_names = [
|
alias_names = [
|
||||||
attr for attr in dir(cls)
|
attr
|
||||||
|
for attr in dir(cls)
|
||||||
if not any(attr.startswith(sp) for sp in skip_prefixes)
|
if not any(attr.startswith(sp) for sp in skip_prefixes)
|
||||||
and not callable(getattr(cls, attr))
|
and not callable(getattr(cls, attr))
|
||||||
]
|
]
|
||||||
|
@ -264,7 +269,6 @@ NORD_THEME_STYLES: dict[str, Style] = {
|
||||||
"blink2": Style(blink2=True),
|
"blink2": Style(blink2=True),
|
||||||
"reverse": Style(reverse=True),
|
"reverse": Style(reverse=True),
|
||||||
"strike": Style(strike=True),
|
"strike": Style(strike=True),
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# Basic color names mapped to Nord
|
# Basic color names mapped to Nord
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
@ -277,7 +281,6 @@ NORD_THEME_STYLES: dict[str, Style] = {
|
||||||
"cyan": Style(color=NordColors.CYAN),
|
"cyan": Style(color=NordColors.CYAN),
|
||||||
"blue": Style(color=NordColors.BLUE),
|
"blue": Style(color=NordColors.BLUE),
|
||||||
"white": Style(color=NordColors.SNOW_STORM_BRIGHTEST),
|
"white": Style(color=NordColors.SNOW_STORM_BRIGHTEST),
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# Inspect
|
# Inspect
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
@ -292,14 +295,12 @@ NORD_THEME_STYLES: dict[str, Style] = {
|
||||||
"inspect.help": Style(color=NordColors.FROST_ICE),
|
"inspect.help": Style(color=NordColors.FROST_ICE),
|
||||||
"inspect.doc": Style(dim=True),
|
"inspect.doc": Style(dim=True),
|
||||||
"inspect.value.border": Style(color=NordColors.GREEN),
|
"inspect.value.border": Style(color=NordColors.GREEN),
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# Live / Layout
|
# Live / Layout
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
"live.ellipsis": Style(bold=True, color=NordColors.RED),
|
"live.ellipsis": Style(bold=True, color=NordColors.RED),
|
||||||
"layout.tree.row": Style(dim=False, color=NordColors.RED),
|
"layout.tree.row": Style(dim=False, color=NordColors.RED),
|
||||||
"layout.tree.column": Style(dim=False, color=NordColors.FROST_DEEP),
|
"layout.tree.column": Style(dim=False, color=NordColors.FROST_DEEP),
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
@ -314,7 +315,6 @@ NORD_THEME_STYLES: dict[str, Style] = {
|
||||||
"log.time": Style(color=NordColors.FROST_ICE, dim=True),
|
"log.time": Style(color=NordColors.FROST_ICE, dim=True),
|
||||||
"log.message": Style.null(),
|
"log.message": Style.null(),
|
||||||
"log.path": Style(dim=True),
|
"log.path": Style(dim=True),
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# Python repr
|
# Python repr
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
@ -340,18 +340,18 @@ NORD_THEME_STYLES: dict[str, Style] = {
|
||||||
"repr.bool_true": Style(color=NordColors.GREEN, italic=True),
|
"repr.bool_true": Style(color=NordColors.GREEN, italic=True),
|
||||||
"repr.bool_false": Style(color=NordColors.RED, italic=True),
|
"repr.bool_false": Style(color=NordColors.RED, italic=True),
|
||||||
"repr.none": Style(color=NordColors.PURPLE, italic=True),
|
"repr.none": Style(color=NordColors.PURPLE, italic=True),
|
||||||
"repr.url": Style(underline=True, color=NordColors.FROST_ICE, italic=False, bold=False),
|
"repr.url": Style(
|
||||||
|
underline=True, color=NordColors.FROST_ICE, italic=False, bold=False
|
||||||
|
),
|
||||||
"repr.uuid": Style(color=NordColors.YELLOW, bold=False),
|
"repr.uuid": Style(color=NordColors.YELLOW, bold=False),
|
||||||
"repr.call": Style(color=NordColors.PURPLE, bold=True),
|
"repr.call": Style(color=NordColors.PURPLE, bold=True),
|
||||||
"repr.path": Style(color=NordColors.PURPLE),
|
"repr.path": Style(color=NordColors.PURPLE),
|
||||||
"repr.filename": Style(color=NordColors.PURPLE),
|
"repr.filename": Style(color=NordColors.PURPLE),
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# Rule
|
# Rule
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
"rule.line": Style(color=NordColors.GREEN),
|
"rule.line": Style(color=NordColors.GREEN),
|
||||||
"rule.text": Style.null(),
|
"rule.text": Style.null(),
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# JSON
|
# JSON
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
@ -362,7 +362,6 @@ NORD_THEME_STYLES: dict[str, Style] = {
|
||||||
"json.number": Style(color=NordColors.FROST_ICE, bold=True, italic=False),
|
"json.number": Style(color=NordColors.FROST_ICE, bold=True, italic=False),
|
||||||
"json.str": Style(color=NordColors.GREEN, italic=False, bold=False),
|
"json.str": Style(color=NordColors.GREEN, italic=False, bold=False),
|
||||||
"json.key": Style(color=NordColors.FROST_ICE, bold=True),
|
"json.key": Style(color=NordColors.FROST_ICE, bold=True),
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# Prompt
|
# Prompt
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
@ -371,12 +370,10 @@ NORD_THEME_STYLES: dict[str, Style] = {
|
||||||
"prompt.default": Style(color=NordColors.FROST_ICE, bold=True),
|
"prompt.default": Style(color=NordColors.FROST_ICE, bold=True),
|
||||||
"prompt.invalid": Style(color=NordColors.RED),
|
"prompt.invalid": Style(color=NordColors.RED),
|
||||||
"prompt.invalid.choice": Style(color=NordColors.RED),
|
"prompt.invalid.choice": Style(color=NordColors.RED),
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# Pretty
|
# Pretty
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
"pretty": Style.null(),
|
"pretty": Style.null(),
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# Scope
|
# Scope
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
@ -384,7 +381,6 @@ NORD_THEME_STYLES: dict[str, Style] = {
|
||||||
"scope.key": Style(color=NordColors.YELLOW, italic=True),
|
"scope.key": Style(color=NordColors.YELLOW, italic=True),
|
||||||
"scope.key.special": Style(color=NordColors.YELLOW, italic=True, dim=True),
|
"scope.key.special": Style(color=NordColors.YELLOW, italic=True, dim=True),
|
||||||
"scope.equals": Style(color=NordColors.RED),
|
"scope.equals": Style(color=NordColors.RED),
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# Table
|
# Table
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
@ -393,7 +389,6 @@ NORD_THEME_STYLES: dict[str, Style] = {
|
||||||
"table.cell": Style.null(),
|
"table.cell": Style.null(),
|
||||||
"table.title": Style(italic=True),
|
"table.title": Style(italic=True),
|
||||||
"table.caption": Style(italic=True, dim=True),
|
"table.caption": Style(italic=True, dim=True),
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# Traceback
|
# Traceback
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
@ -405,7 +400,6 @@ NORD_THEME_STYLES: dict[str, Style] = {
|
||||||
"traceback.exc_type": Style(color=NordColors.RED, bold=True),
|
"traceback.exc_type": Style(color=NordColors.RED, bold=True),
|
||||||
"traceback.exc_value": Style.null(),
|
"traceback.exc_value": Style.null(),
|
||||||
"traceback.offset": Style(color=NordColors.RED, bold=True),
|
"traceback.offset": Style(color=NordColors.RED, bold=True),
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# Progress bars
|
# Progress bars
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
@ -423,13 +417,11 @@ NORD_THEME_STYLES: dict[str, Style] = {
|
||||||
"progress.data.speed": Style(color=NordColors.RED),
|
"progress.data.speed": Style(color=NordColors.RED),
|
||||||
"progress.spinner": Style(color=NordColors.GREEN),
|
"progress.spinner": Style(color=NordColors.GREEN),
|
||||||
"status.spinner": Style(color=NordColors.GREEN),
|
"status.spinner": Style(color=NordColors.GREEN),
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# Tree
|
# Tree
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
"tree": Style(),
|
"tree": Style(),
|
||||||
"tree.line": Style(),
|
"tree.line": Style(),
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# Markdown
|
# Markdown
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
@ -438,8 +430,12 @@ NORD_THEME_STYLES: dict[str, Style] = {
|
||||||
"markdown.em": Style(italic=True),
|
"markdown.em": Style(italic=True),
|
||||||
"markdown.emph": Style(italic=True), # For commonmark compatibility
|
"markdown.emph": Style(italic=True), # For commonmark compatibility
|
||||||
"markdown.strong": Style(bold=True),
|
"markdown.strong": Style(bold=True),
|
||||||
"markdown.code": Style(bold=True, color=NordColors.FROST_ICE, bgcolor=NordColors.POLAR_NIGHT_ORIGIN),
|
"markdown.code": Style(
|
||||||
"markdown.code_block": Style(color=NordColors.FROST_ICE, bgcolor=NordColors.POLAR_NIGHT_ORIGIN),
|
bold=True, color=NordColors.FROST_ICE, bgcolor=NordColors.POLAR_NIGHT_ORIGIN
|
||||||
|
),
|
||||||
|
"markdown.code_block": Style(
|
||||||
|
color=NordColors.FROST_ICE, bgcolor=NordColors.POLAR_NIGHT_ORIGIN
|
||||||
|
),
|
||||||
"markdown.block_quote": Style(color=NordColors.PURPLE),
|
"markdown.block_quote": Style(color=NordColors.PURPLE),
|
||||||
"markdown.list": Style(color=NordColors.FROST_ICE),
|
"markdown.list": Style(color=NordColors.FROST_ICE),
|
||||||
"markdown.item": Style(),
|
"markdown.item": Style(),
|
||||||
|
@ -457,7 +453,6 @@ NORD_THEME_STYLES: dict[str, Style] = {
|
||||||
"markdown.link": Style(color=NordColors.FROST_ICE),
|
"markdown.link": Style(color=NordColors.FROST_ICE),
|
||||||
"markdown.link_url": Style(color=NordColors.FROST_SKY, underline=True),
|
"markdown.link_url": Style(color=NordColors.FROST_SKY, underline=True),
|
||||||
"markdown.s": Style(strike=True),
|
"markdown.s": Style(strike=True),
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# ISO8601
|
# ISO8601
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
@ -504,7 +499,9 @@ if __name__ == "__main__":
|
||||||
console.print(f"Caught error: {error}", style="red")
|
console.print(f"Caught error: {error}", style="red")
|
||||||
|
|
||||||
# Demonstrate a traceback style:
|
# Demonstrate a traceback style:
|
||||||
console.print("\n8) Raising and displaying a traceback with Nord styling:\n", style="bold")
|
console.print(
|
||||||
|
"\n8) Raising and displaying a traceback with Nord styling:\n", style="bold"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
raise ValueError("Nord test exception!")
|
raise ValueError("Nord test exception!")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
|
|
@ -11,8 +11,11 @@ from typing import Any, Awaitable, Callable, TypeVar
|
||||||
|
|
||||||
import pythonjsonlogger.json
|
import pythonjsonlogger.json
|
||||||
from prompt_toolkit import PromptSession
|
from prompt_toolkit import PromptSession
|
||||||
from prompt_toolkit.formatted_text import (AnyFormattedText, FormattedText,
|
from prompt_toolkit.formatted_text import (
|
||||||
merge_formatted_text)
|
AnyFormattedText,
|
||||||
|
FormattedText,
|
||||||
|
merge_formatted_text,
|
||||||
|
)
|
||||||
from rich.logging import RichHandler
|
from rich.logging import RichHandler
|
||||||
|
|
||||||
from falyx.themes.colors import OneColors
|
from falyx.themes.colors import OneColors
|
||||||
|
@ -21,6 +24,7 @@ logger = logging.getLogger("falyx")
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
async def _noop(*args, **kwargs):
|
async def _noop(*args, **kwargs):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@ -44,7 +48,7 @@ def is_coroutine(function: Callable[..., Any]) -> bool:
|
||||||
|
|
||||||
def ensure_async(function: Callable[..., T]) -> Callable[..., Awaitable[T]]:
|
def ensure_async(function: Callable[..., T]) -> Callable[..., Awaitable[T]]:
|
||||||
if is_coroutine(function):
|
if is_coroutine(function):
|
||||||
return function # type: ignore
|
return function # type: ignore
|
||||||
|
|
||||||
@functools.wraps(function)
|
@functools.wraps(function)
|
||||||
async def async_wrapper(*args, **kwargs) -> T:
|
async def async_wrapper(*args, **kwargs) -> T:
|
||||||
|
@ -68,7 +72,9 @@ def chunks(iterator, size):
|
||||||
async def async_confirm(message: AnyFormattedText = "Are you sure?") -> bool:
|
async def async_confirm(message: AnyFormattedText = "Are you sure?") -> bool:
|
||||||
session: PromptSession = PromptSession()
|
session: PromptSession = PromptSession()
|
||||||
while True:
|
while True:
|
||||||
merged_message: AnyFormattedText = merge_formatted_text([message, FormattedText([(OneColors.LIGHT_YELLOW_b, " [Y/n] ")])])
|
merged_message: AnyFormattedText = merge_formatted_text(
|
||||||
|
[message, FormattedText([(OneColors.LIGHT_YELLOW_b, " [Y/n] ")])]
|
||||||
|
)
|
||||||
answer: str = (await session.prompt_async(merged_message)).strip().lower()
|
answer: str = (await session.prompt_async(merged_message)).strip().lower()
|
||||||
if answer in ("y", "yes"):
|
if answer in ("y", "yes"):
|
||||||
return True
|
return True
|
||||||
|
@ -182,7 +188,9 @@ def setup_logging(
|
||||||
elif mode == "json":
|
elif mode == "json":
|
||||||
console_handler = logging.StreamHandler()
|
console_handler = logging.StreamHandler()
|
||||||
console_handler.setFormatter(
|
console_handler.setFormatter(
|
||||||
pythonjsonlogger.json.JsonFormatter("%(asctime)s %(name)s %(levelname)s %(message)s")
|
pythonjsonlogger.json.JsonFormatter(
|
||||||
|
"%(asctime)s %(name)s %(levelname)s %(message)s"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Invalid log mode: {mode}")
|
raise ValueError(f"Invalid log mode: {mode}")
|
||||||
|
@ -194,13 +202,17 @@ def setup_logging(
|
||||||
file_handler.setLevel(file_log_level)
|
file_handler.setLevel(file_log_level)
|
||||||
if json_log_to_file:
|
if json_log_to_file:
|
||||||
file_handler.setFormatter(
|
file_handler.setFormatter(
|
||||||
pythonjsonlogger.json.JsonFormatter("%(asctime)s %(name)s %(levelname)s %(message)s")
|
pythonjsonlogger.json.JsonFormatter(
|
||||||
|
"%(asctime)s %(name)s %(levelname)s %(message)s"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
file_handler.setFormatter(logging.Formatter(
|
file_handler.setFormatter(
|
||||||
"%(asctime)s [%(name)s] [%(levelname)s] %(message)s",
|
logging.Formatter(
|
||||||
datefmt="%Y-%m-%d %H:%M:%S"
|
"%(asctime)s [%(name)s] [%(levelname)s] %(message)s",
|
||||||
))
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
)
|
||||||
root.addHandler(file_handler)
|
root.addHandler(file_handler)
|
||||||
|
|
||||||
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
__version__ = "0.1.6"
|
__version__ = "0.1.7"
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "falyx"
|
name = "falyx"
|
||||||
version = "0.1.6"
|
version = "0.1.7"
|
||||||
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"
|
||||||
|
@ -18,6 +18,12 @@ python-json-logger = "^3.3.0"
|
||||||
pytest = "^7.0"
|
pytest = "^7.0"
|
||||||
pytest-asyncio = "^0.20"
|
pytest-asyncio = "^0.20"
|
||||||
ruff = "^0.3"
|
ruff = "^0.3"
|
||||||
|
toml = "^0.10"
|
||||||
|
black = { version = "^25.0", allow-prereleases = true }
|
||||||
|
mypy = { version = "^1.0", allow-prereleases = true }
|
||||||
|
isort = { version = "^5.0", allow-prereleases = true }
|
||||||
|
pytest-cov = "^4.0"
|
||||||
|
pytest-mock = "^3.0"
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
sync-version = "scripts.sync_version:main"
|
sync-version = "scripts.sync_version:main"
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
"""scripts/sync_version.py"""
|
"""scripts/sync_version.py"""
|
||||||
|
|
||||||
import toml
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import toml
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
|
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
|
||||||
version_path = Path(__file__).parent.parent / "falyx" / "version.py"
|
version_path = Path(__file__).parent.parent / "falyx" / "version.py"
|
||||||
|
@ -13,5 +15,6 @@ def main():
|
||||||
version_path.write_text(f'__version__ = "{version}"\n')
|
version_path.write_text(f'__version__ = "{version}"\n')
|
||||||
print(f"✅ Synced version: {version} → {version_path}")
|
print(f"✅ Synced version: {version} → {version_path}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
|
@ -1,15 +1,17 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from falyx.action import Action, ChainedAction, LiteralInputAction, FallbackAction
|
from falyx.action import Action, ChainedAction, FallbackAction, LiteralInputAction
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
|
||||||
from falyx.context import ExecutionContext
|
from falyx.context import ExecutionContext
|
||||||
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
|
|
||||||
asyncio_default_fixture_loop_scope = "function"
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
|
|
||||||
|
|
||||||
# --- Helpers ---
|
# --- Helpers ---
|
||||||
async def capturing_hook(context: ExecutionContext):
|
async def capturing_hook(context: ExecutionContext):
|
||||||
context.extra["hook_triggered"] = True
|
context.extra["hook_triggered"] = True
|
||||||
|
|
||||||
|
|
||||||
# --- Fixtures ---
|
# --- Fixtures ---
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def clean_registry():
|
def clean_registry():
|
||||||
|
@ -18,7 +20,6 @@ def clean_registry():
|
||||||
er.clear()
|
er.clear()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_action_callable():
|
async def test_action_callable():
|
||||||
"""Test if Action can be created with a callable."""
|
"""Test if Action can be created with a callable."""
|
||||||
|
@ -26,15 +27,22 @@ async def test_action_callable():
|
||||||
result = await action()
|
result = await action()
|
||||||
assert result == "Hello, World!"
|
assert result == "Hello, World!"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_action_async_callable():
|
async def test_action_async_callable():
|
||||||
"""Test if Action can be created with an async callable."""
|
"""Test if Action can be created with an async callable."""
|
||||||
|
|
||||||
async def async_callable():
|
async def async_callable():
|
||||||
return "Hello, World!"
|
return "Hello, World!"
|
||||||
|
|
||||||
action = Action("test_action", async_callable)
|
action = Action("test_action", async_callable)
|
||||||
result = await action()
|
result = await action()
|
||||||
assert result == "Hello, World!"
|
assert result == "Hello, World!"
|
||||||
assert str(action) == "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False)"
|
assert (
|
||||||
|
str(action)
|
||||||
|
== "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_action_non_callable():
|
async def test_action_non_callable():
|
||||||
|
@ -42,11 +50,15 @@ async def test_action_non_callable():
|
||||||
with pytest.raises(TypeError):
|
with pytest.raises(TypeError):
|
||||||
Action("test_action", 42)
|
Action("test_action", 42)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize("return_list, expected", [
|
@pytest.mark.parametrize(
|
||||||
(True, [1, 2, 3]),
|
"return_list, expected",
|
||||||
(False, 3),
|
[
|
||||||
])
|
(True, [1, 2, 3]),
|
||||||
|
(False, 3),
|
||||||
|
],
|
||||||
|
)
|
||||||
async def test_chained_action_return_modes(return_list, expected):
|
async def test_chained_action_return_modes(return_list, expected):
|
||||||
chain = ChainedAction(
|
chain = ChainedAction(
|
||||||
name="Simple Chain",
|
name="Simple Chain",
|
||||||
|
@ -55,19 +67,23 @@ async def test_chained_action_return_modes(return_list, expected):
|
||||||
Action(name="two", action=lambda: 2),
|
Action(name="two", action=lambda: 2),
|
||||||
Action(name="three", action=lambda: 3),
|
Action(name="three", action=lambda: 3),
|
||||||
],
|
],
|
||||||
return_list=return_list
|
return_list=return_list,
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await chain()
|
result = await chain()
|
||||||
assert result == expected
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize("return_list, auto_inject, expected", [
|
@pytest.mark.parametrize(
|
||||||
(True, True, [1, 2, 3]),
|
"return_list, auto_inject, expected",
|
||||||
(True, False, [1, 2, 3]),
|
[
|
||||||
(False, True, 3),
|
(True, True, [1, 2, 3]),
|
||||||
(False, False, 3),
|
(True, False, [1, 2, 3]),
|
||||||
])
|
(False, True, 3),
|
||||||
|
(False, False, 3),
|
||||||
|
],
|
||||||
|
)
|
||||||
async def test_chained_action_literals(return_list, auto_inject, expected):
|
async def test_chained_action_literals(return_list, auto_inject, expected):
|
||||||
chain = ChainedAction(
|
chain = ChainedAction(
|
||||||
name="Literal Chain",
|
name="Literal Chain",
|
||||||
|
@ -79,6 +95,7 @@ async def test_chained_action_literals(return_list, auto_inject, expected):
|
||||||
result = await chain()
|
result = await chain()
|
||||||
assert result == expected
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_literal_input_action():
|
async def test_literal_input_action():
|
||||||
"""Test if LiteralInputAction can be created and used."""
|
"""Test if LiteralInputAction can be created and used."""
|
||||||
|
@ -88,6 +105,7 @@ async def test_literal_input_action():
|
||||||
assert action.value == "Hello, World!"
|
assert action.value == "Hello, World!"
|
||||||
assert str(action) == "LiteralInputAction(value='Hello, World!')"
|
assert str(action) == "LiteralInputAction(value='Hello, World!')"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_fallback_action():
|
async def test_fallback_action():
|
||||||
"""Test if FallbackAction can be created and used."""
|
"""Test if FallbackAction can be created and used."""
|
||||||
|
@ -102,4 +120,3 @@ async def test_fallback_action():
|
||||||
result = await chain()
|
result = await chain()
|
||||||
assert result == "Fallback value"
|
assert result == "Fallback value"
|
||||||
assert str(action) == "FallbackAction(fallback='Fallback value')"
|
assert str(action) == "FallbackAction(fallback='Fallback value')"
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import pickle
|
import pickle
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from falyx.action import ProcessAction
|
from falyx.action import ProcessAction
|
||||||
|
@ -7,17 +8,21 @@ from falyx.execution_registry import ExecutionRegistry as er
|
||||||
|
|
||||||
# --- Fixtures ---
|
# --- Fixtures ---
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def clean_registry():
|
def clean_registry():
|
||||||
er.clear()
|
er.clear()
|
||||||
yield
|
yield
|
||||||
er.clear()
|
er.clear()
|
||||||
|
|
||||||
|
|
||||||
def slow_add(x, y):
|
def slow_add(x, y):
|
||||||
return x + y
|
return x + y
|
||||||
|
|
||||||
|
|
||||||
# --- Tests ---
|
# --- Tests ---
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_process_action_executes_correctly():
|
async def test_process_action_executes_correctly():
|
||||||
with warnings.catch_warnings():
|
with warnings.catch_warnings():
|
||||||
|
@ -27,8 +32,10 @@ async def test_process_action_executes_correctly():
|
||||||
result = await action()
|
result = await action()
|
||||||
assert result == 5
|
assert result == 5
|
||||||
|
|
||||||
|
|
||||||
unpickleable = lambda x: x + 1
|
unpickleable = lambda x: x + 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_process_action_rejects_unpickleable():
|
async def test_process_action_rejects_unpickleable():
|
||||||
with warnings.catch_warnings():
|
with warnings.catch_warnings():
|
||||||
|
@ -37,4 +44,3 @@ async def test_process_action_rejects_unpickleable():
|
||||||
action = ProcessAction(name="proc_fail", func=unpickleable, args=(2,))
|
action = ProcessAction(name="proc_fail", func=unpickleable, args=(2,))
|
||||||
with pytest.raises(pickle.PicklingError, match="Can't pickle"):
|
with pytest.raises(pickle.PicklingError, match="Can't pickle"):
|
||||||
await action()
|
await action()
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ from falyx.retry_utils import enable_retries_recursively
|
||||||
|
|
||||||
asyncio_default_fixture_loop_scope = "function"
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
|
|
||||||
|
|
||||||
# --- Fixtures ---
|
# --- Fixtures ---
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def clean_registry():
|
def clean_registry():
|
||||||
|
@ -13,6 +14,7 @@ def clean_registry():
|
||||||
yield
|
yield
|
||||||
er.clear()
|
er.clear()
|
||||||
|
|
||||||
|
|
||||||
def test_action_enable_retry():
|
def test_action_enable_retry():
|
||||||
"""Test if Action can be created with retry=True."""
|
"""Test if Action can be created with retry=True."""
|
||||||
action = Action("test_action", lambda: "Hello, World!", retry=True)
|
action = Action("test_action", lambda: "Hello, World!", retry=True)
|
||||||
|
|
|
@ -1,16 +1,18 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from falyx.action import Action, ChainedAction, ActionGroup, FallbackAction
|
from falyx.action import Action, ActionGroup, ChainedAction, FallbackAction
|
||||||
|
from falyx.context import ExecutionContext
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
from falyx.hook_manager import HookManager, HookType
|
from falyx.hook_manager import HookManager, HookType
|
||||||
from falyx.context import ExecutionContext
|
|
||||||
|
|
||||||
asyncio_default_fixture_loop_scope = "function"
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
|
|
||||||
|
|
||||||
# --- Helpers ---
|
# --- Helpers ---
|
||||||
async def capturing_hook(context: ExecutionContext):
|
async def capturing_hook(context: ExecutionContext):
|
||||||
context.extra["hook_triggered"] = True
|
context.extra["hook_triggered"] = True
|
||||||
|
|
||||||
|
|
||||||
# --- Fixtures ---
|
# --- Fixtures ---
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def hook_manager():
|
def hook_manager():
|
||||||
|
@ -18,29 +20,33 @@ def hook_manager():
|
||||||
hm.register(HookType.BEFORE, capturing_hook)
|
hm.register(HookType.BEFORE, capturing_hook)
|
||||||
return hm
|
return hm
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def clean_registry():
|
def clean_registry():
|
||||||
er.clear()
|
er.clear()
|
||||||
yield
|
yield
|
||||||
er.clear()
|
er.clear()
|
||||||
|
|
||||||
|
|
||||||
# --- Tests ---
|
# --- Tests ---
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_action_runs_correctly():
|
async def test_action_runs_correctly():
|
||||||
async def dummy_action(x: int = 0) -> int: return x + 1
|
async def dummy_action(x: int = 0) -> int:
|
||||||
|
return x + 1
|
||||||
|
|
||||||
sample_action = Action(name="increment", action=dummy_action, kwargs={"x": 5})
|
sample_action = Action(name="increment", action=dummy_action, kwargs={"x": 5})
|
||||||
result = await sample_action()
|
result = await sample_action()
|
||||||
assert result == 6
|
assert result == 6
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_action_hook_lifecycle(hook_manager):
|
async def test_action_hook_lifecycle(hook_manager):
|
||||||
async def a1(): return 42
|
async def a1():
|
||||||
action = Action(
|
return 42
|
||||||
name="hooked",
|
|
||||||
action=a1,
|
action = Action(name="hooked", action=a1, hooks=hook_manager)
|
||||||
hooks=hook_manager
|
|
||||||
)
|
|
||||||
|
|
||||||
await action()
|
await action()
|
||||||
|
|
||||||
|
@ -48,28 +54,44 @@ async def test_action_hook_lifecycle(hook_manager):
|
||||||
assert context.name == "hooked"
|
assert context.name == "hooked"
|
||||||
assert context.extra.get("hook_triggered") is True
|
assert context.extra.get("hook_triggered") is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_chained_action_with_result_injection():
|
async def test_chained_action_with_result_injection():
|
||||||
async def a1(): return 1
|
async def a1():
|
||||||
async def a2(last_result): return last_result + 5
|
return 1
|
||||||
async def a3(last_result): return last_result * 2
|
|
||||||
|
async def a2(last_result):
|
||||||
|
return last_result + 5
|
||||||
|
|
||||||
|
async def a3(last_result):
|
||||||
|
return last_result * 2
|
||||||
|
|
||||||
actions = [
|
actions = [
|
||||||
Action(name="start", action=a1),
|
Action(name="start", action=a1),
|
||||||
Action(name="add_last", action=a2, inject_last_result=True),
|
Action(name="add_last", action=a2, inject_last_result=True),
|
||||||
Action(name="multiply", action=a3, inject_last_result=True)
|
Action(name="multiply", action=a3, inject_last_result=True),
|
||||||
]
|
]
|
||||||
chain = ChainedAction(name="test_chain", actions=actions, inject_last_result=True, return_list=True)
|
chain = ChainedAction(
|
||||||
|
name="test_chain", actions=actions, inject_last_result=True, return_list=True
|
||||||
|
)
|
||||||
result = await chain()
|
result = await chain()
|
||||||
assert result == [1, 6, 12]
|
assert result == [1, 6, 12]
|
||||||
chain = ChainedAction(name="test_chain", actions=actions, inject_last_result=True)
|
chain = ChainedAction(name="test_chain", actions=actions, inject_last_result=True)
|
||||||
result = await chain()
|
result = await chain()
|
||||||
assert result == 12
|
assert result == 12
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_action_group_runs_in_parallel():
|
async def test_action_group_runs_in_parallel():
|
||||||
async def a1(): return 1
|
async def a1():
|
||||||
async def a2(): return 2
|
return 1
|
||||||
async def a3(): return 3
|
|
||||||
|
async def a2():
|
||||||
|
return 2
|
||||||
|
|
||||||
|
async def a3():
|
||||||
|
return 3
|
||||||
|
|
||||||
actions = [
|
actions = [
|
||||||
Action(name="a", action=a1),
|
Action(name="a", action=a1),
|
||||||
Action(name="b", action=a2),
|
Action(name="b", action=a2),
|
||||||
|
@ -80,10 +102,15 @@ async def test_action_group_runs_in_parallel():
|
||||||
result_dict = dict(result)
|
result_dict = dict(result)
|
||||||
assert result_dict == {"a": 1, "b": 2, "c": 3}
|
assert result_dict == {"a": 1, "b": 2, "c": 3}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_chained_action_inject_from_action():
|
async def test_chained_action_inject_from_action():
|
||||||
async def a1(last_result): return last_result + 10
|
async def a1(last_result):
|
||||||
async def a2(last_result): return last_result + 5
|
return last_result + 10
|
||||||
|
|
||||||
|
async def a2(last_result):
|
||||||
|
return last_result + 5
|
||||||
|
|
||||||
inner_chain = ChainedAction(
|
inner_chain = ChainedAction(
|
||||||
name="inner_chain",
|
name="inner_chain",
|
||||||
actions=[
|
actions=[
|
||||||
|
@ -92,8 +119,13 @@ async def test_chained_action_inject_from_action():
|
||||||
],
|
],
|
||||||
return_list=True,
|
return_list=True,
|
||||||
)
|
)
|
||||||
async def a3(): return 1
|
|
||||||
async def a4(last_result): return last_result + 2
|
async def a3():
|
||||||
|
return 1
|
||||||
|
|
||||||
|
async def a4(last_result):
|
||||||
|
return last_result + 2
|
||||||
|
|
||||||
actions = [
|
actions = [
|
||||||
Action(name="first", action=a3),
|
Action(name="first", action=a3),
|
||||||
Action(name="second", action=a4, inject_last_result=True),
|
Action(name="second", action=a4, inject_last_result=True),
|
||||||
|
@ -103,21 +135,33 @@ async def test_chained_action_inject_from_action():
|
||||||
result = await outer_chain()
|
result = await outer_chain()
|
||||||
assert result == [1, 3, [13, 18]]
|
assert result == [1, 3, [13, 18]]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_chained_action_with_group():
|
async def test_chained_action_with_group():
|
||||||
async def a1(last_result): return last_result + 1
|
async def a1(last_result):
|
||||||
async def a2(last_result): return last_result + 2
|
return last_result + 1
|
||||||
async def a3(): return 3
|
|
||||||
|
async def a2(last_result):
|
||||||
|
return last_result + 2
|
||||||
|
|
||||||
|
async def a3():
|
||||||
|
return 3
|
||||||
|
|
||||||
group = ActionGroup(
|
group = ActionGroup(
|
||||||
name="group",
|
name="group",
|
||||||
actions=[
|
actions=[
|
||||||
Action(name="a", action=a1, inject_last_result=True),
|
Action(name="a", action=a1, inject_last_result=True),
|
||||||
Action(name="b", action=a2, inject_last_result=True),
|
Action(name="b", action=a2, inject_last_result=True),
|
||||||
Action(name="c", action=a3),
|
Action(name="c", action=a3),
|
||||||
]
|
],
|
||||||
)
|
)
|
||||||
async def a4(): return 1
|
|
||||||
async def a5(last_result): return last_result + 2
|
async def a4():
|
||||||
|
return 1
|
||||||
|
|
||||||
|
async def a5(last_result):
|
||||||
|
return last_result + 2
|
||||||
|
|
||||||
actions = [
|
actions = [
|
||||||
Action(name="first", action=a4),
|
Action(name="first", action=a4),
|
||||||
Action(name="second", action=a5, inject_last_result=True),
|
Action(name="second", action=a5, inject_last_result=True),
|
||||||
|
@ -127,6 +171,7 @@ async def test_chained_action_with_group():
|
||||||
result = await chain()
|
result = await chain()
|
||||||
assert result == [1, 3, [("a", 4), ("b", 5), ("c", 3)]]
|
assert result == [1, 3, [("a", 4), ("b", 5), ("c", 3)]]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_action_error_triggers_error_hook():
|
async def test_action_error_triggers_error_hook():
|
||||||
def fail():
|
def fail():
|
||||||
|
@ -146,6 +191,7 @@ async def test_action_error_triggers_error_hook():
|
||||||
|
|
||||||
assert flag.get("called") is True
|
assert flag.get("called") is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_chained_action_rollback_on_failure():
|
async def test_chained_action_rollback_on_failure():
|
||||||
rollback_called = []
|
rollback_called = []
|
||||||
|
@ -161,7 +207,7 @@ async def test_chained_action_rollback_on_failure():
|
||||||
|
|
||||||
actions = [
|
actions = [
|
||||||
Action(name="ok", action=success, rollback=rollback_fn),
|
Action(name="ok", action=success, rollback=rollback_fn),
|
||||||
Action(name="fail", action=fail, rollback=rollback_fn)
|
Action(name="fail", action=fail, rollback=rollback_fn),
|
||||||
]
|
]
|
||||||
|
|
||||||
chain = ChainedAction(name="chain", actions=actions)
|
chain = ChainedAction(name="chain", actions=actions)
|
||||||
|
@ -171,13 +217,17 @@ async def test_chained_action_rollback_on_failure():
|
||||||
|
|
||||||
assert rollback_called == ["rolled back"]
|
assert rollback_called == ["rolled back"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_register_hooks_recursively_propagates():
|
async def test_register_hooks_recursively_propagates():
|
||||||
def hook(context):
|
def hook(context):
|
||||||
context.extra.update({"test_marker": True})
|
context.extra.update({"test_marker": True})
|
||||||
|
|
||||||
async def a1(): return 1
|
async def a1():
|
||||||
async def a2(): return 2
|
return 1
|
||||||
|
|
||||||
|
async def a2():
|
||||||
|
return 2
|
||||||
|
|
||||||
chain = ChainedAction(
|
chain = ChainedAction(
|
||||||
name="chain",
|
name="chain",
|
||||||
|
@ -193,6 +243,7 @@ async def test_register_hooks_recursively_propagates():
|
||||||
for ctx in er.get_by_name("a") + er.get_by_name("b"):
|
for ctx in er.get_by_name("a") + er.get_by_name("b"):
|
||||||
assert ctx.extra.get("test_marker") is True
|
assert ctx.extra.get("test_marker") is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_action_hook_recovers_error():
|
async def test_action_hook_recovers_error():
|
||||||
async def flaky():
|
async def flaky():
|
||||||
|
@ -209,15 +260,26 @@ async def test_action_hook_recovers_error():
|
||||||
result = await action()
|
result = await action()
|
||||||
assert result == 99
|
assert result == 99
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_action_group_injects_last_result():
|
async def test_action_group_injects_last_result():
|
||||||
async def a1(last_result): return last_result + 10
|
async def a1(last_result):
|
||||||
async def a2(last_result): return last_result + 20
|
return last_result + 10
|
||||||
group = ActionGroup(name="group", actions=[
|
|
||||||
Action(name="g1", action=a1, inject_last_result=True),
|
async def a2(last_result):
|
||||||
Action(name="g2", action=a2, inject_last_result=True),
|
return last_result + 20
|
||||||
])
|
|
||||||
async def a3(): return 5
|
group = ActionGroup(
|
||||||
|
name="group",
|
||||||
|
actions=[
|
||||||
|
Action(name="g1", action=a1, inject_last_result=True),
|
||||||
|
Action(name="g2", action=a2, inject_last_result=True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def a3():
|
||||||
|
return 5
|
||||||
|
|
||||||
chain = ChainedAction(
|
chain = ChainedAction(
|
||||||
name="with_group",
|
name="with_group",
|
||||||
actions=[
|
actions=[
|
||||||
|
@ -230,20 +292,30 @@ async def test_action_group_injects_last_result():
|
||||||
result_dict = dict(result[1])
|
result_dict = dict(result[1])
|
||||||
assert result_dict == {"g1": 15, "g2": 25}
|
assert result_dict == {"g1": 15, "g2": 25}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_action_inject_last_result():
|
async def test_action_inject_last_result():
|
||||||
async def a1(): return 1
|
async def a1():
|
||||||
async def a2(last_result): return last_result + 1
|
return 1
|
||||||
|
|
||||||
|
async def a2(last_result):
|
||||||
|
return last_result + 1
|
||||||
|
|
||||||
a1 = Action(name="a1", action=a1)
|
a1 = Action(name="a1", action=a1)
|
||||||
a2 = Action(name="a2", action=a2, inject_last_result=True)
|
a2 = Action(name="a2", action=a2, inject_last_result=True)
|
||||||
chain = ChainedAction(name="chain", actions=[a1, a2])
|
chain = ChainedAction(name="chain", actions=[a1, a2])
|
||||||
result = await chain()
|
result = await chain()
|
||||||
assert result == 2
|
assert result == 2
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_action_inject_last_result_fail():
|
async def test_action_inject_last_result_fail():
|
||||||
async def a1(): return 1
|
async def a1():
|
||||||
async def a2(last_result): return last_result + 1
|
return 1
|
||||||
|
|
||||||
|
async def a2(last_result):
|
||||||
|
return last_result + 1
|
||||||
|
|
||||||
a1 = Action(name="a1", action=a1)
|
a1 = Action(name="a1", action=a1)
|
||||||
a2 = Action(name="a2", action=a2)
|
a2 = Action(name="a2", action=a2)
|
||||||
chain = ChainedAction(name="chain", actions=[a1, a2])
|
chain = ChainedAction(name="chain", actions=[a1, a2])
|
||||||
|
@ -253,54 +325,82 @@ async def test_action_inject_last_result_fail():
|
||||||
|
|
||||||
assert "last_result" in str(exc_info.value)
|
assert "last_result" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_chained_action_auto_inject():
|
async def test_chained_action_auto_inject():
|
||||||
async def a1(): return 1
|
async def a1():
|
||||||
async def a2(last_result): return last_result + 2
|
return 1
|
||||||
|
|
||||||
|
async def a2(last_result):
|
||||||
|
return last_result + 2
|
||||||
|
|
||||||
a1 = Action(name="a1", action=a1)
|
a1 = Action(name="a1", action=a1)
|
||||||
a2 = Action(name="a2", action=a2)
|
a2 = Action(name="a2", action=a2)
|
||||||
chain = ChainedAction(name="chain", actions=[a1, a2], auto_inject=True, return_list=True)
|
chain = ChainedAction(
|
||||||
|
name="chain", actions=[a1, a2], auto_inject=True, return_list=True
|
||||||
|
)
|
||||||
result = await chain()
|
result = await chain()
|
||||||
assert result == [1, 3] # a2 receives last_result=1
|
assert result == [1, 3] # a2 receives last_result=1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_chained_action_no_auto_inject():
|
async def test_chained_action_no_auto_inject():
|
||||||
async def a1(): return 1
|
async def a1():
|
||||||
async def a2(): return 2
|
return 1
|
||||||
|
|
||||||
|
async def a2():
|
||||||
|
return 2
|
||||||
|
|
||||||
a1 = Action(name="a1", action=a1)
|
a1 = Action(name="a1", action=a1)
|
||||||
a2 = Action(name="a2", action=a2)
|
a2 = Action(name="a2", action=a2)
|
||||||
chain = ChainedAction(name="no_inject", actions=[a1, a2], auto_inject=False, return_list=True)
|
chain = ChainedAction(
|
||||||
|
name="no_inject", actions=[a1, a2], auto_inject=False, return_list=True
|
||||||
|
)
|
||||||
result = await chain()
|
result = await chain()
|
||||||
assert result == [1, 2] # a2 does not receive 1
|
assert result == [1, 2] # a2 does not receive 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_chained_action_auto_inject_after_first():
|
async def test_chained_action_auto_inject_after_first():
|
||||||
async def a1(): return 1
|
async def a1():
|
||||||
async def a2(last_result): return last_result + 1
|
return 1
|
||||||
|
|
||||||
|
async def a2(last_result):
|
||||||
|
return last_result + 1
|
||||||
|
|
||||||
a1 = Action(name="a1", action=a1)
|
a1 = Action(name="a1", action=a1)
|
||||||
a2 = Action(name="a2", action=a2)
|
a2 = Action(name="a2", action=a2)
|
||||||
chain = ChainedAction(name="auto_inject", actions=[a1, a2], auto_inject=True)
|
chain = ChainedAction(name="auto_inject", actions=[a1, a2], auto_inject=True)
|
||||||
result = await chain()
|
result = await chain()
|
||||||
assert result == 2 # a2 receives last_result=1
|
assert result == 2 # a2 receives last_result=1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_chained_action_with_literal_input():
|
async def test_chained_action_with_literal_input():
|
||||||
async def a1(last_result): return last_result + " world"
|
async def a1(last_result):
|
||||||
|
return last_result + " world"
|
||||||
|
|
||||||
a1 = Action(name="a1", action=a1)
|
a1 = Action(name="a1", action=a1)
|
||||||
chain = ChainedAction(name="literal_inject", actions=["hello", a1], auto_inject=True)
|
chain = ChainedAction(name="literal_inject", actions=["hello", a1], auto_inject=True)
|
||||||
result = await chain()
|
result = await chain()
|
||||||
assert result == "hello world" # "hello" is injected as last_result
|
assert result == "hello world" # "hello" is injected as last_result
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_chained_action_manual_inject_override():
|
async def test_chained_action_manual_inject_override():
|
||||||
async def a1(): return 10
|
async def a1():
|
||||||
async def a2(last_result): return last_result * 2
|
return 10
|
||||||
|
|
||||||
|
async def a2(last_result):
|
||||||
|
return last_result * 2
|
||||||
|
|
||||||
a1 = Action(name="a1", action=a1)
|
a1 = Action(name="a1", action=a1)
|
||||||
a2 = Action(name="a2", action=a2, inject_last_result=True)
|
a2 = Action(name="a2", action=a2, inject_last_result=True)
|
||||||
chain = ChainedAction(name="manual_override", actions=[a1, a2], auto_inject=False)
|
chain = ChainedAction(name="manual_override", actions=[a1, a2], auto_inject=False)
|
||||||
result = await chain()
|
result = await chain()
|
||||||
assert result == 20 # Even without auto_inject, a2 still gets last_result
|
assert result == 20 # Even without auto_inject, a2 still gets last_result
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_chained_action_with_mid_literal():
|
async def test_chained_action_with_mid_literal():
|
||||||
async def fetch_data():
|
async def fetch_data():
|
||||||
|
@ -330,6 +430,7 @@ async def test_chained_action_with_mid_literal():
|
||||||
result = await chain()
|
result = await chain()
|
||||||
assert result == [None, "default_value", "default_value", "Enriched: default_value"]
|
assert result == [None, "default_value", "default_value", "Enriched: default_value"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_chained_action_with_mid_fallback():
|
async def test_chained_action_with_mid_fallback():
|
||||||
async def fetch_data():
|
async def fetch_data():
|
||||||
|
@ -389,15 +490,22 @@ async def test_chained_action_with_success_mid_fallback():
|
||||||
result = await chain()
|
result = await chain()
|
||||||
assert result == ["Result", "Result", "Result", "Enriched: Result"]
|
assert result == ["Result", "Result", "Result", "Enriched: Result"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_action_group_partial_failure():
|
async def test_action_group_partial_failure():
|
||||||
async def succeed(): return "ok"
|
async def succeed():
|
||||||
async def fail(): raise ValueError("oops")
|
return "ok"
|
||||||
|
|
||||||
group = ActionGroup(name="partial_group", actions=[
|
async def fail():
|
||||||
Action(name="succeed_action", action=succeed),
|
raise ValueError("oops")
|
||||||
Action(name="fail_action", action=fail),
|
|
||||||
])
|
group = ActionGroup(
|
||||||
|
name="partial_group",
|
||||||
|
actions=[
|
||||||
|
Action(name="succeed_action", action=succeed),
|
||||||
|
Action(name="fail_action", action=fail),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
with pytest.raises(Exception) as exc_info:
|
with pytest.raises(Exception) as exc_info:
|
||||||
await group()
|
await group()
|
||||||
|
@ -406,10 +514,15 @@ async def test_action_group_partial_failure():
|
||||||
assert er.get_by_name("fail_action")[0].exception is not None
|
assert er.get_by_name("fail_action")[0].exception is not None
|
||||||
assert "fail_action" in str(exc_info.value)
|
assert "fail_action" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_chained_action_with_nested_group():
|
async def test_chained_action_with_nested_group():
|
||||||
async def g1(last_result): return last_result + "10"
|
async def g1(last_result):
|
||||||
async def g2(last_result): return last_result + "20"
|
return last_result + "10"
|
||||||
|
|
||||||
|
async def g2(last_result):
|
||||||
|
return last_result + "20"
|
||||||
|
|
||||||
group = ActionGroup(
|
group = ActionGroup(
|
||||||
name="nested_group",
|
name="nested_group",
|
||||||
actions=[
|
actions=[
|
||||||
|
@ -431,7 +544,11 @@ async def test_chained_action_with_nested_group():
|
||||||
result = await chain()
|
result = await chain()
|
||||||
# "start" -> group both receive "start" as last_result
|
# "start" -> group both receive "start" as last_result
|
||||||
assert result[0] == "start"
|
assert result[0] == "start"
|
||||||
assert dict(result[1]) == {"g1": "start10", "g2": "start20"} # Assuming string concatenation for example
|
assert dict(result[1]) == {
|
||||||
|
"g1": "start10",
|
||||||
|
"g2": "start20",
|
||||||
|
} # Assuming string concatenation for example
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_chained_action_double_fallback():
|
async def test_chained_action_double_fallback():
|
||||||
|
@ -461,5 +578,11 @@ async def test_chained_action_double_fallback():
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await chain()
|
result = await chain()
|
||||||
assert result == [None, "default1", "default1", None, "default2", "Enriched: default2"]
|
assert result == [
|
||||||
|
None,
|
||||||
|
"default1",
|
||||||
|
"default1",
|
||||||
|
None,
|
||||||
|
"default2",
|
||||||
|
"Enriched: default2",
|
||||||
|
]
|
||||||
|
|
|
@ -3,6 +3,7 @@ import pytest
|
||||||
from falyx.action import ChainedAction
|
from falyx.action import ChainedAction
|
||||||
from falyx.exceptions import EmptyChainError
|
from falyx.exceptions import EmptyChainError
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_chained_action_raises_empty_chain_error_when_no_actions():
|
async def test_chained_action_raises_empty_chain_error_when_no_actions():
|
||||||
"""A ChainedAction with no actions should raise an EmptyChainError immediately."""
|
"""A ChainedAction with no actions should raise an EmptyChainError immediately."""
|
||||||
|
@ -14,6 +15,7 @@ async def test_chained_action_raises_empty_chain_error_when_no_actions():
|
||||||
assert "No actions to execute." in str(exc_info.value)
|
assert "No actions to execute." in str(exc_info.value)
|
||||||
assert "empty_chain" in str(exc_info.value)
|
assert "empty_chain" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_chained_action_raises_empty_chain_error_when_actions_are_none():
|
async def test_chained_action_raises_empty_chain_error_when_actions_are_none():
|
||||||
"""A ChainedAction with None as actions should raise an EmptyChainError immediately."""
|
"""A ChainedAction with None as actions should raise an EmptyChainError immediately."""
|
||||||
|
@ -24,4 +26,3 @@ async def test_chained_action_raises_empty_chain_error_when_actions_are_none():
|
||||||
|
|
||||||
assert "No actions to execute." in str(exc_info.value)
|
assert "No actions to execute." in str(exc_info.value)
|
||||||
assert "none_chain" in str(exc_info.value)
|
assert "none_chain" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
|
@ -3,12 +3,13 @@ import pytest
|
||||||
|
|
||||||
from falyx.action import Action, ActionGroup, ChainedAction
|
from falyx.action import Action, ActionGroup, ChainedAction
|
||||||
from falyx.command import Command
|
from falyx.command import Command
|
||||||
from falyx.io_action import BaseIOAction
|
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
|
from falyx.io_action import BaseIOAction
|
||||||
from falyx.retry import RetryPolicy
|
from falyx.retry import RetryPolicy
|
||||||
|
|
||||||
asyncio_default_fixture_loop_scope = "function"
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
|
|
||||||
|
|
||||||
# --- Fixtures ---
|
# --- Fixtures ---
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def clean_registry():
|
def clean_registry():
|
||||||
|
@ -16,10 +17,12 @@ def clean_registry():
|
||||||
yield
|
yield
|
||||||
er.clear()
|
er.clear()
|
||||||
|
|
||||||
|
|
||||||
# --- Dummy Action ---
|
# --- Dummy Action ---
|
||||||
async def dummy_action():
|
async def dummy_action():
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
# --- Dummy IO Action ---
|
# --- Dummy IO Action ---
|
||||||
class DummyInputAction(BaseIOAction):
|
class DummyInputAction(BaseIOAction):
|
||||||
async def _run(self, *args, **kwargs):
|
async def _run(self, *args, **kwargs):
|
||||||
|
@ -28,46 +31,46 @@ class DummyInputAction(BaseIOAction):
|
||||||
async def preview(self, parent=None):
|
async def preview(self, parent=None):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# --- Tests ---
|
# --- Tests ---
|
||||||
def test_command_creation():
|
def test_command_creation():
|
||||||
"""Test if Command can be created with a callable."""
|
"""Test if Command can be created with a callable."""
|
||||||
action = Action("test_action", dummy_action)
|
action = Action("test_action", dummy_action)
|
||||||
cmd = Command(
|
cmd = Command(key="TEST", description="Test Command", action=action)
|
||||||
key="TEST",
|
|
||||||
description="Test Command",
|
|
||||||
action=action
|
|
||||||
)
|
|
||||||
assert cmd.key == "TEST"
|
assert cmd.key == "TEST"
|
||||||
assert cmd.description == "Test Command"
|
assert cmd.description == "Test Command"
|
||||||
assert cmd.action == action
|
assert cmd.action == action
|
||||||
|
|
||||||
|
|
||||||
def test_command_str():
|
def test_command_str():
|
||||||
"""Test if Command string representation is correct."""
|
"""Test if Command string representation is correct."""
|
||||||
action = Action("test_action", dummy_action)
|
action = Action("test_action", dummy_action)
|
||||||
cmd = Command(
|
cmd = Command(key="TEST", description="Test Command", action=action)
|
||||||
key="TEST",
|
|
||||||
description="Test Command",
|
|
||||||
action=action
|
|
||||||
)
|
|
||||||
print(cmd)
|
print(cmd)
|
||||||
assert str(cmd) == "Command(key='TEST', description='Test Command' action='Action(name='test_action', action=dummy_action, args=(), kwargs={}, retry=False)')"
|
assert (
|
||||||
|
str(cmd)
|
||||||
|
== "Command(key='TEST', description='Test Command' action='Action(name='test_action', action=dummy_action, args=(), kwargs={}, retry=False)')"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"action_factory, expected_requires_input",
|
"action_factory, expected_requires_input",
|
||||||
[
|
[
|
||||||
(lambda: Action(name="normal", action=dummy_action), False),
|
(lambda: Action(name="normal", action=dummy_action), False),
|
||||||
(lambda: DummyInputAction(name="io"), True),
|
(lambda: DummyInputAction(name="io"), True),
|
||||||
(lambda: ChainedAction(name="chain", actions=[DummyInputAction(name="io")]), True),
|
(
|
||||||
(lambda: ActionGroup(name="group", actions=[DummyInputAction(name="io")]), True),
|
lambda: ChainedAction(name="chain", actions=[DummyInputAction(name="io")]),
|
||||||
]
|
True,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
lambda: ActionGroup(name="group", actions=[DummyInputAction(name="io")]),
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
def test_command_requires_input_detection(action_factory, expected_requires_input):
|
def test_command_requires_input_detection(action_factory, expected_requires_input):
|
||||||
action = action_factory()
|
action = action_factory()
|
||||||
cmd = Command(
|
cmd = Command(key="TEST", description="Test Command", action=action)
|
||||||
key="TEST",
|
|
||||||
description="Test Command",
|
|
||||||
action=action
|
|
||||||
)
|
|
||||||
|
|
||||||
assert cmd.requires_input == expected_requires_input
|
assert cmd.requires_input == expected_requires_input
|
||||||
if expected_requires_input:
|
if expected_requires_input:
|
||||||
|
@ -75,6 +78,7 @@ def test_command_requires_input_detection(action_factory, expected_requires_inpu
|
||||||
else:
|
else:
|
||||||
assert cmd.hidden is False
|
assert cmd.hidden is False
|
||||||
|
|
||||||
|
|
||||||
def test_requires_input_flag_detected_for_baseioaction():
|
def test_requires_input_flag_detected_for_baseioaction():
|
||||||
"""Command should automatically detect requires_input=True for BaseIOAction."""
|
"""Command should automatically detect requires_input=True for BaseIOAction."""
|
||||||
cmd = Command(
|
cmd = Command(
|
||||||
|
@ -85,6 +89,7 @@ def test_requires_input_flag_detected_for_baseioaction():
|
||||||
assert cmd.requires_input is True
|
assert cmd.requires_input is True
|
||||||
assert cmd.hidden is True
|
assert cmd.hidden is True
|
||||||
|
|
||||||
|
|
||||||
def test_requires_input_manual_override():
|
def test_requires_input_manual_override():
|
||||||
"""Command manually set requires_input=False should not auto-hide."""
|
"""Command manually set requires_input=False should not auto-hide."""
|
||||||
cmd = Command(
|
cmd = Command(
|
||||||
|
@ -96,6 +101,7 @@ def test_requires_input_manual_override():
|
||||||
assert cmd.requires_input is False
|
assert cmd.requires_input is False
|
||||||
assert cmd.hidden is False
|
assert cmd.hidden is False
|
||||||
|
|
||||||
|
|
||||||
def test_default_command_does_not_require_input():
|
def test_default_command_does_not_require_input():
|
||||||
"""Normal Command without IO Action should not require input."""
|
"""Normal Command without IO Action should not require input."""
|
||||||
cmd = Command(
|
cmd = Command(
|
||||||
|
@ -106,6 +112,7 @@ def test_default_command_does_not_require_input():
|
||||||
assert cmd.requires_input is False
|
assert cmd.requires_input is False
|
||||||
assert cmd.hidden is False
|
assert cmd.hidden is False
|
||||||
|
|
||||||
|
|
||||||
def test_chain_requires_input():
|
def test_chain_requires_input():
|
||||||
"""If first action in a chain requires input, the command should require input."""
|
"""If first action in a chain requires input, the command should require input."""
|
||||||
chain = ChainedAction(
|
chain = ChainedAction(
|
||||||
|
@ -123,6 +130,7 @@ def test_chain_requires_input():
|
||||||
assert cmd.requires_input is True
|
assert cmd.requires_input is True
|
||||||
assert cmd.hidden is True
|
assert cmd.hidden is True
|
||||||
|
|
||||||
|
|
||||||
def test_group_requires_input():
|
def test_group_requires_input():
|
||||||
"""If any action in a group requires input, the command should require input."""
|
"""If any action in a group requires input, the command should require input."""
|
||||||
group = ActionGroup(
|
group = ActionGroup(
|
||||||
|
@ -155,6 +163,7 @@ def test_enable_retry():
|
||||||
assert cmd.retry is True
|
assert cmd.retry is True
|
||||||
assert cmd.action.retry_policy.enabled is True
|
assert cmd.action.retry_policy.enabled is True
|
||||||
|
|
||||||
|
|
||||||
def test_enable_retry_with_retry_policy():
|
def test_enable_retry_with_retry_policy():
|
||||||
"""Command should enable retry if action is an Action and retry_policy is set."""
|
"""Command should enable retry if action is an Action and retry_policy is set."""
|
||||||
retry_policy = RetryPolicy(
|
retry_policy = RetryPolicy(
|
||||||
|
@ -175,6 +184,7 @@ def test_enable_retry_with_retry_policy():
|
||||||
assert cmd.action.retry_policy.enabled is True
|
assert cmd.action.retry_policy.enabled is True
|
||||||
assert cmd.action.retry_policy == retry_policy
|
assert cmd.action.retry_policy == retry_policy
|
||||||
|
|
||||||
|
|
||||||
def test_enable_retry_not_action():
|
def test_enable_retry_not_action():
|
||||||
"""Command should not enable retry if action is not an Action."""
|
"""Command should not enable retry if action is not an Action."""
|
||||||
cmd = Command(
|
cmd = Command(
|
||||||
|
@ -188,6 +198,7 @@ def test_enable_retry_not_action():
|
||||||
assert cmd.action.retry_policy.enabled is False
|
assert cmd.action.retry_policy.enabled is False
|
||||||
assert "'function' object has no attribute 'retry_policy'" in str(exc_info.value)
|
assert "'function' object has no attribute 'retry_policy'" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
def test_chain_retry_all():
|
def test_chain_retry_all():
|
||||||
"""retry_all should retry all Actions inside a ChainedAction recursively."""
|
"""retry_all should retry all Actions inside a ChainedAction recursively."""
|
||||||
chain = ChainedAction(
|
chain = ChainedAction(
|
||||||
|
@ -209,6 +220,7 @@ def test_chain_retry_all():
|
||||||
assert chain.actions[0].retry_policy.enabled is True
|
assert chain.actions[0].retry_policy.enabled is True
|
||||||
assert chain.actions[1].retry_policy.enabled is True
|
assert chain.actions[1].retry_policy.enabled is True
|
||||||
|
|
||||||
|
|
||||||
def test_chain_retry_all_not_base_action():
|
def test_chain_retry_all_not_base_action():
|
||||||
"""retry_all should not be set if action is not a ChainedAction."""
|
"""retry_all should not be set if action is not a ChainedAction."""
|
||||||
cmd = Command(
|
cmd = Command(
|
||||||
|
@ -221,4 +233,3 @@ def test_chain_retry_all_not_base_action():
|
||||||
with pytest.raises(Exception) as exc_info:
|
with pytest.raises(Exception) as exc_info:
|
||||||
assert cmd.action.retry_policy.enabled is False
|
assert cmd.action.retry_policy.enabled is False
|
||||||
assert "'function' object has no attribute 'retry_policy'" in str(exc_info.value)
|
assert "'function' object has no attribute 'retry_policy'" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import pytest
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from falyx.action import Action, ChainedAction, ActionGroup, FallbackAction
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from falyx.action import Action, ActionGroup, ChainedAction, FallbackAction
|
||||||
|
from falyx.context import ExecutionContext
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
from falyx.hook_manager import HookType
|
from falyx.hook_manager import HookType
|
||||||
from falyx.context import ExecutionContext
|
|
||||||
|
|
||||||
# --- Fixtures ---
|
# --- Fixtures ---
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue