Compare commits
No commits in common. "876c006f4439f34a437693cc37c1157d54682fa7" and "74555c44841cf53a5d50e2f0c561fb6f4b180b0b" have entirely different histories.
876c006f44
...
74555c4484
@ -39,7 +39,7 @@ pip install falyx
|
|||||||
> Or install from source:
|
> Or install from source:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/rolandtjr/falyx.git
|
git clone https://github.com/yourname/falyx.git
|
||||||
cd falyx
|
cd falyx
|
||||||
poetry install
|
poetry install
|
||||||
```
|
```
|
||||||
|
@ -38,5 +38,4 @@ def build_falyx() -> Falyx:
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
logging.basicConfig(level=logging.WARNING)
|
logging.basicConfig(level=logging.WARNING)
|
||||||
falyx = build_falyx()
|
falyx = build_falyx()
|
||||||
asyncio.run(falyx.run())
|
asyncio.run(falyx.cli())
|
||||||
|
|
||||||
|
@ -471,6 +471,8 @@ class ProcessAction(BaseAction):
|
|||||||
try:
|
try:
|
||||||
import pickle
|
import pickle
|
||||||
pickle.dumps(obj)
|
pickle.dumps(obj)
|
||||||
|
print("YES")
|
||||||
return True
|
return True
|
||||||
except (pickle.PicklingError, TypeError):
|
except (pickle.PicklingError, TypeError):
|
||||||
|
print("NO")
|
||||||
return False
|
return False
|
||||||
|
@ -1,92 +1,53 @@
|
|||||||
"""bottom_bar.py"""
|
"""bottom_bar.py"""
|
||||||
|
from typing import Any, Callable, Optional
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
from prompt_toolkit.formatted_text import HTML, merge_formatted_text
|
from prompt_toolkit.formatted_text import HTML, merge_formatted_text
|
||||||
from prompt_toolkit.key_binding import KeyBindings
|
from prompt_toolkit.key_binding import KeyBindings
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
from falyx.options_manager import OptionsManager
|
|
||||||
from falyx.themes.colors import OneColors
|
from falyx.themes.colors import OneColors
|
||||||
from falyx.utils import CaseInsensitiveDict
|
from falyx.utils import CaseInsensitiveDict
|
||||||
|
|
||||||
|
|
||||||
class BottomBar:
|
class BottomBar:
|
||||||
"""
|
"""Bottom Bar class for displaying a bottom bar in the terminal."""
|
||||||
Bottom Bar class for displaying a bottom bar in the terminal.
|
def __init__(self, columns: int = 3, key_bindings: KeyBindings | None = None):
|
||||||
|
|
||||||
Args:
|
|
||||||
columns (int): Number of columns in the bottom bar.
|
|
||||||
key_bindings (KeyBindings, optional): Key bindings for the bottom bar.
|
|
||||||
key_validator (Callable[[str], bool], optional): Function to validate toggle keys.
|
|
||||||
Must return True if key is available, otherwise False.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
columns: int = 3,
|
|
||||||
key_bindings: KeyBindings | None = None,
|
|
||||||
key_validator: Callable[[str], bool] | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.columns = columns
|
self.columns = columns
|
||||||
self.console = Console()
|
self.console = Console()
|
||||||
self._items: list[Callable[[], HTML]] = []
|
self._items: list[Callable[[], HTML]] = []
|
||||||
self._named_items: dict[str, Callable[[], HTML]] = {}
|
self._named_items: dict[str, Callable[[], HTML]] = {}
|
||||||
self._value_getters: dict[str, Callable[[], Any]] = CaseInsensitiveDict()
|
self._states: dict[str, Any] = CaseInsensitiveDict()
|
||||||
self.toggle_keys: list[str] = []
|
self.toggles: list[str] = []
|
||||||
self.key_bindings = key_bindings or KeyBindings()
|
self.key_bindings = key_bindings or KeyBindings()
|
||||||
self.key_validator = key_validator
|
|
||||||
|
|
||||||
@staticmethod
|
def get_space(self) -> int:
|
||||||
def default_render(label: str, value: Any, fg: str, bg: str, width: int) -> HTML:
|
|
||||||
return HTML(f"<style fg='{fg}' bg='{bg}'>{label}: {value:^{width}}</style>")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def space(self) -> int:
|
|
||||||
return self.console.width // self.columns
|
return self.console.width // self.columns
|
||||||
|
|
||||||
def add_custom(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
render_fn: Callable[[], HTML]
|
|
||||||
) -> None:
|
|
||||||
"""Add a custom render function to the bottom bar."""
|
|
||||||
if not callable(render_fn):
|
|
||||||
raise ValueError("`render_fn` must be callable")
|
|
||||||
self._add_named(name, render_fn)
|
|
||||||
|
|
||||||
def add_static(
|
def add_static(
|
||||||
self,
|
self, name: str, text: str, fg: str = OneColors.BLACK, bg: str = OneColors.WHITE
|
||||||
name: str,
|
|
||||||
text: str,
|
|
||||||
fg: str = OneColors.BLACK,
|
|
||||||
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.get_space()}}</style>"
|
||||||
)
|
)
|
||||||
|
|
||||||
self._add_named(name, render)
|
self._add_named(name, render)
|
||||||
|
|
||||||
def add_value_tracker(
|
def add_counter(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
label: str,
|
label: str,
|
||||||
get_value: Callable[[], Any],
|
current: int,
|
||||||
fg: str = OneColors.BLACK,
|
fg: str = OneColors.BLACK,
|
||||||
bg: str = OneColors.WHITE,
|
bg: str = OneColors.WHITE,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not callable(get_value):
|
self._states[name] = (label, current)
|
||||||
raise ValueError("`get_value` must be a callable returning any value")
|
|
||||||
self._value_getters[name] = get_value
|
|
||||||
|
|
||||||
def render():
|
def render():
|
||||||
get_value_ = self._value_getters[name]
|
label_, current_ = self._states[name]
|
||||||
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.get_space()}}</style>"
|
||||||
)
|
)
|
||||||
|
|
||||||
self._add_named(name, render)
|
self._add_named(name, render)
|
||||||
@ -95,26 +56,23 @@ class BottomBar:
|
|||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
label: str,
|
label: str,
|
||||||
get_current: Callable[[], int],
|
current: int,
|
||||||
total: int,
|
total: int,
|
||||||
fg: str = OneColors.BLACK,
|
fg: str = OneColors.BLACK,
|
||||||
bg: str = OneColors.WHITE,
|
bg: str = OneColors.WHITE,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not callable(get_current):
|
self._states[name] = (label, current, total)
|
||||||
raise ValueError("`get_current` must be a callable returning int")
|
|
||||||
|
|
||||||
self._value_getters[name] = get_current
|
if current > total:
|
||||||
|
raise ValueError(
|
||||||
|
f"Current value {current} is greater than total value {total}"
|
||||||
|
)
|
||||||
|
|
||||||
def render():
|
def render():
|
||||||
get_current_ = self._value_getters[name]
|
label_, current_, text_ = self._states[name]
|
||||||
current_value = get_current_()
|
text = f"{label_}: {current_}/{text_}"
|
||||||
if current_value > total:
|
|
||||||
raise ValueError(
|
|
||||||
f"Current value {current_value} is greater than total 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.get_space()}}</style>"
|
||||||
)
|
)
|
||||||
|
|
||||||
self._add_named(name, render)
|
self._add_named(name, render)
|
||||||
@ -123,31 +81,24 @@ class BottomBar:
|
|||||||
self,
|
self,
|
||||||
key: str,
|
key: str,
|
||||||
label: str,
|
label: str,
|
||||||
get_state: Callable[[], bool],
|
state: bool,
|
||||||
toggle_state: Callable[[], None],
|
|
||||||
fg: str = OneColors.BLACK,
|
fg: str = OneColors.BLACK,
|
||||||
bg_on: str = OneColors.GREEN,
|
bg_on: str = OneColors.GREEN,
|
||||||
bg_off: str = OneColors.DARK_RED,
|
bg_off: str = OneColors.DARK_RED,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not callable(get_state):
|
|
||||||
raise ValueError("`get_state` must be a callable returning bool")
|
|
||||||
if not callable(toggle_state):
|
|
||||||
raise ValueError("`toggle_state` must be a callable")
|
|
||||||
key = key.upper()
|
key = key.upper()
|
||||||
if key in self.toggle_keys:
|
if key in self.toggles:
|
||||||
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):
|
self._states[key] = (label, state)
|
||||||
raise ValueError(f"Key '{key}' conflicts with existing command, toggle, or reserved key.")
|
self.toggles.append(key)
|
||||||
self._value_getters[key] = get_state
|
|
||||||
self.toggle_keys.append(key)
|
|
||||||
|
|
||||||
def render():
|
def render():
|
||||||
get_state_ = self._value_getters[key]
|
label_, state_ = self._states[key]
|
||||||
color = bg_on if get_state_() else bg_off
|
color = bg_on if state_ else bg_off
|
||||||
status = "ON" if get_state_() else "OFF"
|
status = "ON" if 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.get_space()}}</style>"
|
||||||
)
|
)
|
||||||
|
|
||||||
self._add_named(key, render)
|
self._add_named(key, render)
|
||||||
@ -155,43 +106,43 @@ class BottomBar:
|
|||||||
for k in (key.upper(), key.lower()):
|
for k in (key.upper(), key.lower()):
|
||||||
|
|
||||||
@self.key_bindings.add(k)
|
@self.key_bindings.add(k)
|
||||||
def _(event):
|
def _(event, key=k):
|
||||||
toggle_state()
|
self.toggle_state(key)
|
||||||
|
|
||||||
def add_toggle_from_option(
|
def toggle_state(self, name: str) -> bool:
|
||||||
self,
|
label, state = self._states.get(name, (None, False))
|
||||||
key: str,
|
new_state = not state
|
||||||
label: str,
|
self.update_toggle(name, new_state)
|
||||||
options: OptionsManager,
|
return new_state
|
||||||
option_name: str,
|
|
||||||
namespace_name: str = "cli_args",
|
def update_toggle(self, name: str, state: bool) -> None:
|
||||||
fg: str = OneColors.BLACK,
|
if name in self._states:
|
||||||
bg_on: str = OneColors.GREEN,
|
label, _ = self._states[name]
|
||||||
bg_off: str = OneColors.DARK_RED,
|
self._states[name] = (label, state)
|
||||||
|
|
||||||
|
def increment_counter(self, name: str) -> None:
|
||||||
|
if name in self._states:
|
||||||
|
label, current = self._states[name]
|
||||||
|
self._states[name] = (label, current + 1)
|
||||||
|
|
||||||
|
def increment_total_counter(self, name: str) -> None:
|
||||||
|
if name in self._states:
|
||||||
|
label, current, total = self._states[name]
|
||||||
|
if current < total:
|
||||||
|
self._states[name] = (label, current + 1, total)
|
||||||
|
|
||||||
|
def update_counter(
|
||||||
|
self, name: str, current: Optional[int] = None, total: Optional[int] = None
|
||||||
) -> None:
|
) -> None:
|
||||||
self.add_toggle(
|
if name in self._states:
|
||||||
key=key,
|
label, c, t = self._states[name]
|
||||||
label=label,
|
self._states[name] = (
|
||||||
get_state=options.get_value_getter(option_name, namespace_name),
|
label,
|
||||||
toggle_state=options.get_toggle_function(option_name, namespace_name),
|
current if current is not None else c,
|
||||||
fg=fg,
|
total if total is not None else t,
|
||||||
bg_on=bg_on,
|
)
|
||||||
bg_off=bg_off,
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def values(self) -> dict[str, Any]:
|
|
||||||
"""Return the current computed values for all registered items."""
|
|
||||||
return {label: getter() for label, getter in self._value_getters.items()}
|
|
||||||
|
|
||||||
def get_value(self, name: str) -> Any:
|
|
||||||
if name not in self._value_getters:
|
|
||||||
raise ValueError(f"No value getter registered under name: '{name}'")
|
|
||||||
return self._value_getters[name]()
|
|
||||||
|
|
||||||
def _add_named(self, name: str, render_fn: Callable[[], HTML]) -> None:
|
def _add_named(self, name: str, render_fn: Callable[[], HTML]) -> None:
|
||||||
if name in self._named_items:
|
|
||||||
raise ValueError(f"Bottom bar item '{name}' already exists")
|
|
||||||
self._named_items[name] = render_fn
|
self._named_items[name] = render_fn
|
||||||
self._items = list(self._named_items.values())
|
self._items = list(self._named_items.values())
|
||||||
|
|
||||||
|
@ -6,6 +6,8 @@ from typing import Any
|
|||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
|
console = Console(color_system="auto")
|
||||||
|
|
||||||
|
|
||||||
class ExecutionContext(BaseModel):
|
class ExecutionContext(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
@ -21,7 +23,6 @@ class ExecutionContext(BaseModel):
|
|||||||
end_wall: datetime | None = None
|
end_wall: datetime | None = None
|
||||||
|
|
||||||
extra: dict[str, Any] = Field(default_factory=dict)
|
extra: dict[str, Any] = Field(default_factory=dict)
|
||||||
console: Console = Field(default_factory=lambda: Console(color_system="auto"))
|
|
||||||
|
|
||||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
|
|
||||||
@ -74,7 +75,7 @@ class ExecutionContext(BaseModel):
|
|||||||
message.append(f"❌ Exception: {summary['exception']}")
|
message.append(f"❌ Exception: {summary['exception']}")
|
||||||
else:
|
else:
|
||||||
message.append(f"✅ Result: {summary['result']}")
|
message.append(f"✅ Result: {summary['result']}")
|
||||||
(logger or self.console.print)("".join(message))
|
(logger or console.print)("".join(message))
|
||||||
|
|
||||||
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."""
|
||||||
|
135
falyx/falyx.py
135
falyx/falyx.py
@ -39,8 +39,7 @@ from falyx.exceptions import (CommandAlreadyExistsError, FalyxError,
|
|||||||
InvalidActionError, NotAFalyxError)
|
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.parsers import FalyxParsers, 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, logger
|
from falyx.utils import CaseInsensitiveDict, async_confirm, chunks, logger
|
||||||
@ -79,7 +78,7 @@ class Falyx:
|
|||||||
title: str | Markdown = "Menu",
|
title: str | Markdown = "Menu",
|
||||||
prompt: str | AnyFormattedText = "> ",
|
prompt: str | AnyFormattedText = "> ",
|
||||||
columns: int = 3,
|
columns: int = 3,
|
||||||
bottom_bar: BottomBar | str | Callable[[], Any] | None = None,
|
bottom_bar: BottomBar | str | Callable[[], None] | None = None,
|
||||||
welcome_message: str | Markdown | dict[str, Any] = "",
|
welcome_message: str | Markdown | dict[str, Any] = "",
|
||||||
exit_message: str | Markdown | dict[str, Any] = "",
|
exit_message: str | Markdown | dict[str, Any] = "",
|
||||||
key_bindings: KeyBindings | None = None,
|
key_bindings: KeyBindings | None = None,
|
||||||
@ -89,7 +88,6 @@ class Falyx:
|
|||||||
never_confirm: bool = False,
|
never_confirm: bool = False,
|
||||||
always_confirm: bool = False,
|
always_confirm: bool = False,
|
||||||
cli_args: Namespace | None = None,
|
cli_args: Namespace | None = None,
|
||||||
options: OptionsManager | 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."""
|
||||||
@ -106,35 +104,12 @@ class Falyx:
|
|||||||
self.hooks: HookManager = HookManager()
|
self.hooks: HookManager = HookManager()
|
||||||
self.last_run_command: Command | None = None
|
self.last_run_command: Command | None = None
|
||||||
self.key_bindings: KeyBindings = key_bindings or KeyBindings()
|
self.key_bindings: KeyBindings = key_bindings or KeyBindings()
|
||||||
self.bottom_bar: BottomBar | str | Callable[[], None] = bottom_bar
|
self.bottom_bar: BottomBar | str | Callable[[], None] = bottom_bar or BottomBar(columns=columns, key_bindings=self.key_bindings)
|
||||||
self.confirm_on_error: bool = confirm_on_error
|
self.confirm_on_error: bool = confirm_on_error
|
||||||
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.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)
|
|
||||||
|
|
||||||
def set_options(
|
|
||||||
self,
|
|
||||||
cli_args: Namespace | None,
|
|
||||||
options: OptionsManager | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Checks if the options are set correctly."""
|
|
||||||
self.options: OptionsManager = options or OptionsManager()
|
|
||||||
if not cli_args and not options:
|
|
||||||
return
|
|
||||||
|
|
||||||
if options and not cli_args:
|
|
||||||
raise FalyxError("Options are set, but CLI arguments are not.")
|
|
||||||
|
|
||||||
if options is None:
|
|
||||||
self.options.from_namespace(cli_args, "cli_args")
|
|
||||||
|
|
||||||
if not isinstance(self.options, OptionsManager):
|
|
||||||
raise FalyxError("Options must be an instance of OptionsManager.")
|
|
||||||
|
|
||||||
if not isinstance(self.cli_args, Namespace):
|
|
||||||
raise FalyxError("CLI arguments must be a Namespace object.")
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _name_map(self) -> dict[str, Command]:
|
def _name_map(self) -> dict[str, Command]:
|
||||||
@ -273,8 +248,8 @@ class Falyx:
|
|||||||
keys.add(cmd.key.upper())
|
keys.add(cmd.key.upper())
|
||||||
keys.update({alias.upper() for alias in cmd.aliases})
|
keys.update({alias.upper() for alias in cmd.aliases})
|
||||||
|
|
||||||
if isinstance(self._bottom_bar, BottomBar):
|
if isinstance(self.bottom_bar, BottomBar):
|
||||||
toggle_keys = {key.upper() for key in self._bottom_bar.toggle_keys}
|
toggle_keys = {key.upper() for key in self.bottom_bar.toggles}
|
||||||
else:
|
else:
|
||||||
toggle_keys = set()
|
toggle_keys = set()
|
||||||
|
|
||||||
@ -302,47 +277,57 @@ class Falyx:
|
|||||||
if hasattr(self, "session"):
|
if hasattr(self, "session"):
|
||||||
del self.session
|
del self.session
|
||||||
|
|
||||||
|
def add_toggle(self, key: str, label: str, state: bool) -> None:
|
||||||
|
"""Adds a toggle to the bottom bar."""
|
||||||
|
assert isinstance(self.bottom_bar, BottomBar), "Bottom bar must be an instance of BottomBar."
|
||||||
|
self.bottom_bar.add_toggle(key, label, state)
|
||||||
|
self._invalidate_session_cache()
|
||||||
|
|
||||||
|
def add_counter(self, name: str, label: str, current: int) -> None:
|
||||||
|
"""Adds a counter to the bottom bar."""
|
||||||
|
assert isinstance(self.bottom_bar, BottomBar), "Bottom bar must be an instance of BottomBar."
|
||||||
|
self.bottom_bar.add_counter(name, label, current)
|
||||||
|
self._invalidate_session_cache()
|
||||||
|
|
||||||
|
def add_total_counter(self, name: str, label: str, current: int, total: int) -> None:
|
||||||
|
"""Adds a counter to the bottom bar."""
|
||||||
|
assert isinstance(self.bottom_bar, BottomBar), "Bottom bar must be an instance of BottomBar."
|
||||||
|
self.bottom_bar.add_total_counter(name, label, current, total)
|
||||||
|
self._invalidate_session_cache()
|
||||||
|
|
||||||
|
def add_static(self, name: str, text: str) -> None:
|
||||||
|
"""Adds a static element to the bottom bar."""
|
||||||
|
assert isinstance(self.bottom_bar, BottomBar), "Bottom bar must be an instance of BottomBar."
|
||||||
|
self.bottom_bar.add_static(name, text)
|
||||||
|
self._invalidate_session_cache
|
||||||
|
|
||||||
|
def get_toggle_state(self, key: str) -> bool | None:
|
||||||
|
assert isinstance(self.bottom_bar, BottomBar), "Bottom bar must be an instance of BottomBar."
|
||||||
|
if key.upper() in self.bottom_bar._states:
|
||||||
|
"""Returns the state of a toggle."""
|
||||||
|
return self.bottom_bar._states[key.upper()][1]
|
||||||
|
return None
|
||||||
|
|
||||||
def add_help_command(self):
|
def add_help_command(self):
|
||||||
"""Adds a help command to the menu if it doesn't already exist."""
|
"""Adds a help command to the menu if it doesn't already exist."""
|
||||||
if not self.help_command:
|
if not self.help_command:
|
||||||
self.help_command = self._get_help_command()
|
self.help_command = self._get_help_command()
|
||||||
|
self._invalidate_session_cache()
|
||||||
|
|
||||||
def add_history_command(self):
|
def add_history_command(self):
|
||||||
"""Adds a history command to the menu if it doesn't already exist."""
|
"""Adds a history command to the menu if it doesn't already exist."""
|
||||||
if not self.history_command:
|
if not self.history_command:
|
||||||
self.history_command = self._get_history_command()
|
self.history_command = self._get_history_command()
|
||||||
|
self._invalidate_session_cache()
|
||||||
|
|
||||||
@property
|
def _get_bottom_bar(self) -> Callable[[], Any] | str | None:
|
||||||
def bottom_bar(self) -> BottomBar | str | Callable[[], Any] | None:
|
|
||||||
"""Returns the bottom bar for the menu."""
|
|
||||||
return self._bottom_bar
|
|
||||||
|
|
||||||
@bottom_bar.setter
|
|
||||||
def bottom_bar(self, bottom_bar: BottomBar | str | Callable[[], Any] | None) -> None:
|
|
||||||
"""Sets the bottom bar for the menu."""
|
|
||||||
if bottom_bar is None:
|
|
||||||
self._bottom_bar = BottomBar(self.columns, self.key_bindings, key_validator=self.is_key_available)
|
|
||||||
elif isinstance(bottom_bar, BottomBar):
|
|
||||||
bottom_bar.key_validator = self.is_key_available
|
|
||||||
bottom_bar.key_bindings = self.key_bindings
|
|
||||||
self._bottom_bar = bottom_bar
|
|
||||||
elif (isinstance(bottom_bar, str) or
|
|
||||||
callable(bottom_bar)):
|
|
||||||
self._bottom_bar = bottom_bar
|
|
||||||
else:
|
|
||||||
raise FalyxError("Bottom bar must be a string, callable, or BottomBar instance.")
|
|
||||||
self._invalidate_session_cache()
|
|
||||||
|
|
||||||
def _get_bottom_bar_render(self) -> Callable[[], Any] | str | None:
|
|
||||||
"""Returns the bottom bar for the menu."""
|
"""Returns the bottom bar for the menu."""
|
||||||
if isinstance(self.bottom_bar, BottomBar) and self.bottom_bar._items:
|
if isinstance(self.bottom_bar, BottomBar) and self.bottom_bar._items:
|
||||||
return self._bottom_bar.render
|
return self.bottom_bar.render
|
||||||
elif callable(self._bottom_bar):
|
elif callable(self.bottom_bar):
|
||||||
return self._bottom_bar
|
return self.bottom_bar
|
||||||
elif isinstance(self._bottom_bar, str):
|
elif isinstance(self.bottom_bar, str):
|
||||||
return self._bottom_bar
|
return self.bottom_bar
|
||||||
elif self._bottom_bar is None:
|
|
||||||
return None
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
@ -354,7 +339,7 @@ class Falyx:
|
|||||||
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(),
|
||||||
key_bindings=self.key_bindings,
|
key_bindings=self.key_bindings,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -397,24 +382,10 @@ class Falyx:
|
|||||||
logger.debug(f"[Command '{key}'] after: {hook_names(command.hooks._hooks[HookType.AFTER])}")
|
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])}")
|
logger.debug(f"[Command '{key}'] on_teardown: {hook_names(command.hooks._hooks[HookType.ON_TEARDOWN])}")
|
||||||
|
|
||||||
def is_key_available(self, key: str) -> bool:
|
|
||||||
key = key.upper()
|
|
||||||
toggles = self._bottom_bar.toggle_keys if isinstance(self._bottom_bar, BottomBar) else []
|
|
||||||
|
|
||||||
conflicts = (
|
|
||||||
key in self.commands,
|
|
||||||
key == self.exit_command.key.upper(),
|
|
||||||
self.history_command and key == self.history_command.key.upper(),
|
|
||||||
self.help_command and key == self.help_command.key.upper(),
|
|
||||||
key in toggles
|
|
||||||
)
|
|
||||||
|
|
||||||
return not any(conflicts)
|
|
||||||
|
|
||||||
def _validate_command_key(self, key: str) -> None:
|
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.toggles if isinstance(self.bottom_bar, BottomBar) else []
|
||||||
collisions = []
|
collisions = []
|
||||||
|
|
||||||
if key in self.commands:
|
if key in self.commands:
|
||||||
@ -452,6 +423,7 @@ class Falyx:
|
|||||||
confirm=confirm,
|
confirm=confirm,
|
||||||
confirm_message=confirm_message,
|
confirm_message=confirm_message,
|
||||||
)
|
)
|
||||||
|
self._invalidate_session_cache()
|
||||||
|
|
||||||
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."""
|
||||||
@ -459,6 +431,7 @@ class Falyx:
|
|||||||
raise NotAFalyxError("submenu must be an instance of Falyx.")
|
raise NotAFalyxError("submenu must be an instance of Falyx.")
|
||||||
self._validate_command_key(key)
|
self._validate_command_key(key)
|
||||||
self.add_command(key, description, submenu.menu, color=color)
|
self.add_command(key, description, submenu.menu, color=color)
|
||||||
|
self._invalidate_session_cache()
|
||||||
|
|
||||||
def add_commands(self, commands: list[dict]) -> None:
|
def add_commands(self, commands: list[dict]) -> None:
|
||||||
"""Adds multiple commands to the menu."""
|
"""Adds multiple commands to the menu."""
|
||||||
@ -538,6 +511,7 @@ class Falyx:
|
|||||||
command.hooks.register(HookType.ON_TEARDOWN, hook)
|
command.hooks.register(HookType.ON_TEARDOWN, hook)
|
||||||
|
|
||||||
self.commands[key] = command
|
self.commands[key] = command
|
||||||
|
self._invalidate_session_cache()
|
||||||
return command
|
return command
|
||||||
|
|
||||||
def get_bottom_row(self) -> list[str]:
|
def get_bottom_row(self) -> list[str]:
|
||||||
@ -781,10 +755,10 @@ class Falyx:
|
|||||||
if self.exit_message:
|
if self.exit_message:
|
||||||
self.print_message(self.exit_message)
|
self.print_message(self.exit_message)
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self, parsers: FalyxParsers | None = None) -> None:
|
||||||
"""Run Falyx CLI with structured subcommands."""
|
"""Run Falyx CLI with structured subcommands."""
|
||||||
if not self.cli_args:
|
parsers = parsers or get_arg_parsers()
|
||||||
self.cli_args = get_arg_parsers().root.parse_args()
|
self.cli_args = parsers.root.parse_args()
|
||||||
|
|
||||||
if self.cli_args.verbose:
|
if self.cli_args.verbose:
|
||||||
logging.getLogger("falyx").setLevel(logging.DEBUG)
|
logging.getLogger("falyx").setLevel(logging.DEBUG)
|
||||||
@ -817,10 +791,11 @@ class Falyx:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
self._set_retry_policy(command)
|
self._set_retry_policy(command)
|
||||||
try:
|
try:
|
||||||
await self.headless(self.cli_args.name)
|
result = await self.headless(self.cli_args.name)
|
||||||
except FalyxError as error:
|
except FalyxError as error:
|
||||||
self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]")
|
self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
self.console.print(f"[{OneColors.GREEN}]✅ Result:[/] {result}")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
if self.cli_args.command == "run-all":
|
if self.cli_args.command == "run-all":
|
||||||
@ -832,7 +807,7 @@ class Falyx:
|
|||||||
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"[bold cyan]🚀 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)
|
||||||
|
@ -3,29 +3,9 @@ import time
|
|||||||
|
|
||||||
from falyx.context import ExecutionContext
|
from falyx.context import ExecutionContext
|
||||||
from falyx.exceptions import CircuitBreakerOpen
|
from falyx.exceptions import CircuitBreakerOpen
|
||||||
from falyx.themes.colors import OneColors
|
|
||||||
from falyx.utils import logger
|
from falyx.utils import logger
|
||||||
|
|
||||||
|
|
||||||
class ResultReporter:
|
|
||||||
def __init__(self, formatter: callable = None):
|
|
||||||
"""
|
|
||||||
Optional result formatter. If not provided, uses repr(result).
|
|
||||||
"""
|
|
||||||
self.formatter = formatter or (lambda r: repr(r))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def __name__(self):
|
|
||||||
return "ResultReporter"
|
|
||||||
|
|
||||||
async def report(self, context: ExecutionContext):
|
|
||||||
if context.result is not None:
|
|
||||||
result_text = self.formatter(context.result)
|
|
||||||
duration = f"{context.duration:.3f}s" if context.duration is not None else "n/a"
|
|
||||||
context.console.print(f"[{OneColors.GREEN}]✅ '{context.name}' "
|
|
||||||
f"completed:[/] {result_text} in {duration}.")
|
|
||||||
|
|
||||||
|
|
||||||
class CircuitBreaker:
|
class CircuitBreaker:
|
||||||
def __init__(self, max_failures=3, reset_timeout=10):
|
def __init__(self, max_failures=3, reset_timeout=10):
|
||||||
self.max_failures = max_failures
|
self.max_failures = max_failures
|
||||||
@ -61,3 +41,4 @@ class CircuitBreaker:
|
|||||||
self.failures = 0
|
self.failures = 0
|
||||||
self.open_until = None
|
self.open_until = None
|
||||||
logger.info("🔄 Circuit reset.")
|
logger.info("🔄 Circuit reset.")
|
||||||
|
|
||||||
|
@ -2,9 +2,8 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from rich.markdown import Markdown
|
from rich.markdown import Markdown
|
||||||
|
|
||||||
from falyx import Action, Falyx
|
from falyx import Action, Falyx, HookType
|
||||||
from falyx.hook_manager import HookType
|
from falyx.hooks import log_before, log_success, log_error, log_after
|
||||||
from falyx.debug import log_before, log_success, log_error, log_after
|
|
||||||
from falyx.themes.colors import OneColors
|
from falyx.themes.colors import OneColors
|
||||||
from falyx.utils import setup_logging
|
from falyx.utils import setup_logging
|
||||||
|
|
||||||
@ -77,7 +76,7 @@ def main():
|
|||||||
spinner=True,
|
spinner=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
asyncio.run(menu.run())
|
asyncio.run(menu.cli())
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
@ -85,4 +84,4 @@ if __name__ == "__main__":
|
|||||||
Entry point for the Falyx CLI demo application.
|
Entry point for the Falyx CLI demo application.
|
||||||
This function initializes the menu and runs it.
|
This function initializes the menu and runs it.
|
||||||
"""
|
"""
|
||||||
main()
|
main()
|
@ -1,72 +0,0 @@
|
|||||||
"""options_manager.py"""
|
|
||||||
|
|
||||||
from argparse import Namespace
|
|
||||||
from collections import defaultdict
|
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
from falyx.utils import logger
|
|
||||||
|
|
||||||
|
|
||||||
class OptionsManager:
|
|
||||||
def __init__(self, namespaces: list[tuple[str, Namespace]] = None) -> None:
|
|
||||||
self.options = defaultdict(lambda: Namespace())
|
|
||||||
if namespaces:
|
|
||||||
for namespace_name, namespace in namespaces:
|
|
||||||
self.from_namespace(namespace, namespace_name)
|
|
||||||
|
|
||||||
def from_namespace(
|
|
||||||
self, namespace: Namespace, namespace_name: str = "cli_args"
|
|
||||||
) -> None:
|
|
||||||
self.options[namespace_name] = namespace
|
|
||||||
|
|
||||||
def get(
|
|
||||||
self, option_name: str, default: Any = None, namespace_name: str = "cli_args"
|
|
||||||
) -> Any:
|
|
||||||
"""Get the value of an option."""
|
|
||||||
return getattr(self.options[namespace_name], option_name, default)
|
|
||||||
|
|
||||||
def set(
|
|
||||||
self, option_name: str, value: Any, namespace_name: str = "cli_args"
|
|
||||||
) -> None:
|
|
||||||
"""Set the value of an option."""
|
|
||||||
setattr(self.options[namespace_name], option_name, value)
|
|
||||||
|
|
||||||
def has_option(self, option_name: str, namespace_name: str = "cli_args") -> bool:
|
|
||||||
"""Check if an option exists in the namespace."""
|
|
||||||
return hasattr(self.options[namespace_name], option_name)
|
|
||||||
|
|
||||||
def toggle(self, option_name: str, namespace_name: str = "cli_args") -> None:
|
|
||||||
"""Toggle a boolean option."""
|
|
||||||
current = self.get(option_name, namespace_name=namespace_name)
|
|
||||||
if not isinstance(current, bool):
|
|
||||||
raise TypeError(
|
|
||||||
f"Cannot toggle non-boolean option: '{option_name}' in '{namespace_name}'"
|
|
||||||
)
|
|
||||||
self.set(option_name, not current, namespace_name=namespace_name)
|
|
||||||
logger.debug(f"Toggled '{option_name}' in '{namespace_name}' to {not current}")
|
|
||||||
|
|
||||||
def get_value_getter(
|
|
||||||
self, option_name: str, namespace_name: str = "cli_args"
|
|
||||||
) -> Callable[[], Any]:
|
|
||||||
"""Get the value of an option as a getter function."""
|
|
||||||
|
|
||||||
def _getter() -> Any:
|
|
||||||
return self.get(option_name, namespace_name=namespace_name)
|
|
||||||
|
|
||||||
return _getter
|
|
||||||
|
|
||||||
def get_toggle_function(
|
|
||||||
self, option_name: str, namespace_name: str = "cli_args"
|
|
||||||
) -> Callable[[], None]:
|
|
||||||
"""Get the toggle function for a boolean option."""
|
|
||||||
|
|
||||||
def _toggle() -> None:
|
|
||||||
self.toggle(option_name, namespace_name=namespace_name)
|
|
||||||
|
|
||||||
return _toggle
|
|
||||||
|
|
||||||
def get_namespace_dict(self, namespace_name: str) -> Namespace:
|
|
||||||
"""Return all options in a namespace as a dictionary."""
|
|
||||||
if namespace_name not in self.options:
|
|
||||||
raise ValueError(f"Namespace '{namespace_name}' not found.")
|
|
||||||
return vars(self.options[namespace_name])
|
|
@ -1,9 +1,8 @@
|
|||||||
"""parsers.py
|
"""parsers.py
|
||||||
This module contains the argument parsers used for the Falyx CLI.
|
This module contains the argument parsers used for the Falyx CLI.
|
||||||
"""
|
"""
|
||||||
from argparse import ArgumentParser, HelpFormatter, Namespace
|
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
from typing import Any, Sequence
|
from argparse import ArgumentParser
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -16,10 +15,6 @@ class FalyxParsers:
|
|||||||
list: ArgumentParser
|
list: ArgumentParser
|
||||||
version: ArgumentParser
|
version: ArgumentParser
|
||||||
|
|
||||||
def parse_args(self, args: Sequence[str] | None = None) -> Namespace:
|
|
||||||
"""Parse the command line arguments."""
|
|
||||||
return self.root.parse_args(args)
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, ArgumentParser]:
|
def as_dict(self) -> dict[str, ArgumentParser]:
|
||||||
"""Convert the FalyxParsers instance to a dictionary."""
|
"""Convert the FalyxParsers instance to a dictionary."""
|
||||||
return asdict(self)
|
return asdict(self)
|
||||||
@ -29,37 +24,9 @@ class FalyxParsers:
|
|||||||
return self.as_dict().get(name)
|
return self.as_dict().get(name)
|
||||||
|
|
||||||
|
|
||||||
def get_arg_parsers(
|
def get_arg_parsers() -> FalyxParsers:
|
||||||
prog: str |None = "falyx",
|
|
||||||
usage: str | None = None,
|
|
||||||
description: str | None = "Falyx CLI - Run structured async command workflows.",
|
|
||||||
epilog: str | None = None,
|
|
||||||
parents: Sequence[ArgumentParser] = [],
|
|
||||||
formatter_class: HelpFormatter = HelpFormatter,
|
|
||||||
prefix_chars: str = "-",
|
|
||||||
fromfile_prefix_chars: str | None = None,
|
|
||||||
argument_default: Any = None,
|
|
||||||
conflict_handler: str = "error",
|
|
||||||
add_help: bool = True,
|
|
||||||
allow_abbrev: bool = True,
|
|
||||||
exit_on_error: bool = True,
|
|
||||||
) -> FalyxParsers:
|
|
||||||
"""Returns the argument parser for the CLI."""
|
"""Returns the argument parser for the CLI."""
|
||||||
parser = ArgumentParser(
|
parser = ArgumentParser(prog="falyx", description="Falyx CLI - Run structured async command workflows.")
|
||||||
prog=prog,
|
|
||||||
usage=usage,
|
|
||||||
description=description,
|
|
||||||
epilog=epilog,
|
|
||||||
parents=parents,
|
|
||||||
formatter_class=formatter_class,
|
|
||||||
prefix_chars=prefix_chars,
|
|
||||||
fromfile_prefix_chars=fromfile_prefix_chars,
|
|
||||||
argument_default=argument_default,
|
|
||||||
conflict_handler=conflict_handler,
|
|
||||||
add_help=add_help,
|
|
||||||
allow_abbrev=allow_abbrev,
|
|
||||||
exit_on_error=exit_on_error,
|
|
||||||
)
|
|
||||||
parser.add_argument("-v", "--verbose", action="store_true", help="Enable debug logging for Falyx.")
|
parser.add_argument("-v", "--verbose", action="store_true", help="Enable debug logging for Falyx.")
|
||||||
parser.add_argument("--debug-hooks", action="store_true", help="Enable default lifecycle debug logging")
|
parser.add_argument("--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")
|
||||||
|
@ -1 +1 @@
|
|||||||
__version__ = "0.1.5"
|
__version__ = "0.1.0"
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "falyx"
|
name = "falyx"
|
||||||
version = "0.1.5"
|
version = "0.1.2"
|
||||||
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"
|
||||||
@ -12,12 +12,12 @@ python = ">=3.10"
|
|||||||
prompt_toolkit = "^3.0"
|
prompt_toolkit = "^3.0"
|
||||||
rich = "^13.0"
|
rich = "^13.0"
|
||||||
pydantic = "^2.0"
|
pydantic = "^2.0"
|
||||||
python-json-logger = "^3.3.0"
|
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
pytest = "^7.0"
|
pytest = "^7.0"
|
||||||
pytest-asyncio = "^0.20"
|
pytest-asyncio = "^0.20"
|
||||||
ruff = "^0.3"
|
ruff = "^0.3"
|
||||||
|
python-json-logger = "^3.3.0"
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
falyx = "falyx.cli.main:main"
|
falyx = "falyx.cli.main:main"
|
||||||
|
18
setup.py
Normal file
18
setup.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from setuptools import setup, find_packages
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name="falyx",
|
||||||
|
version="0.0.1",
|
||||||
|
description="Reserved package name for future CLI framework.",
|
||||||
|
long_description=open("README.md").read(),
|
||||||
|
long_description_content_type="text/markdown",
|
||||||
|
author="Roland Thomas Jr",
|
||||||
|
author_email="roland@rtj.dev",
|
||||||
|
packages=find_packages(),
|
||||||
|
python_requires=">=3.10",
|
||||||
|
classifiers=[
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Development Status :: 1 - Planning",
|
||||||
|
],
|
||||||
|
)
|
Loading…
x
Reference in New Issue
Block a user