falyx 0.1.23__py3-none-any.whl → 0.1.25__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- falyx/__init__.py +1 -1
- falyx/action/__init__.py +41 -0
- falyx/{action.py → action/action.py} +41 -23
- falyx/{action_factory.py → action/action_factory.py} +17 -5
- falyx/{http_action.py → action/http_action.py} +10 -9
- falyx/{io_action.py → action/io_action.py} +19 -14
- falyx/{menu_action.py → action/menu_action.py} +9 -79
- falyx/{select_file_action.py → action/select_file_action.py} +5 -36
- falyx/{selection_action.py → action/selection_action.py} +22 -8
- falyx/action/signal_action.py +43 -0
- falyx/action/types.py +37 -0
- falyx/bottom_bar.py +3 -3
- falyx/command.py +13 -10
- falyx/config.py +17 -9
- falyx/context.py +16 -8
- falyx/debug.py +2 -1
- falyx/exceptions.py +3 -0
- falyx/execution_registry.py +59 -13
- falyx/falyx.py +67 -77
- falyx/hook_manager.py +20 -3
- falyx/hooks.py +13 -6
- falyx/init.py +1 -0
- falyx/logger.py +5 -0
- falyx/menu.py +85 -0
- falyx/options_manager.py +7 -3
- falyx/parsers.py +2 -2
- falyx/prompt_utils.py +30 -1
- falyx/protocols.py +2 -1
- falyx/retry.py +23 -12
- falyx/retry_utils.py +2 -1
- falyx/selection.py +7 -3
- falyx/signals.py +3 -0
- falyx/tagged_table.py +2 -1
- falyx/themes/__init__.py +15 -0
- falyx/utils.py +11 -39
- falyx/validators.py +8 -7
- falyx/version.py +1 -1
- {falyx-0.1.23.dist-info → falyx-0.1.25.dist-info}/METADATA +2 -1
- falyx-0.1.25.dist-info/RECORD +46 -0
- falyx/signal_action.py +0 -30
- falyx-0.1.23.dist-info/RECORD +0 -41
- {falyx-0.1.23.dist-info → falyx-0.1.25.dist-info}/LICENSE +0 -0
- {falyx-0.1.23.dist-info → falyx-0.1.25.dist-info}/WHEEL +0 -0
- {falyx-0.1.23.dist-info → falyx-0.1.25.dist-info}/entry_points.txt +0 -0
falyx/falyx.py
CHANGED
@@ -38,7 +38,7 @@ from rich.console import Console
|
|
38
38
|
from rich.markdown import Markdown
|
39
39
|
from rich.table import Table
|
40
40
|
|
41
|
-
from falyx.action import Action, BaseAction
|
41
|
+
from falyx.action.action import Action, BaseAction
|
42
42
|
from falyx.bottom_bar import BottomBar
|
43
43
|
from falyx.command import Command
|
44
44
|
from falyx.context import ExecutionContext
|
@@ -51,12 +51,13 @@ from falyx.exceptions import (
|
|
51
51
|
)
|
52
52
|
from falyx.execution_registry import ExecutionRegistry as er
|
53
53
|
from falyx.hook_manager import Hook, HookManager, HookType
|
54
|
+
from falyx.logger import logger
|
54
55
|
from falyx.options_manager import OptionsManager
|
55
56
|
from falyx.parsers import get_arg_parsers
|
56
57
|
from falyx.retry import RetryPolicy
|
57
58
|
from falyx.signals import BackSignal, QuitSignal
|
58
|
-
from falyx.themes
|
59
|
-
from falyx.utils import CaseInsensitiveDict, chunks, get_program_invocation
|
59
|
+
from falyx.themes import OneColors, get_nord_theme
|
60
|
+
from falyx.utils import CaseInsensitiveDict, chunks, get_program_invocation
|
60
61
|
from falyx.version import __version__
|
61
62
|
|
62
63
|
|
@@ -78,7 +79,8 @@ class Falyx:
|
|
78
79
|
Key Features:
|
79
80
|
- Interactive menu with Rich rendering and Prompt Toolkit input handling
|
80
81
|
- Dynamic command management with alias and abbreviation matching
|
81
|
-
- Full lifecycle hooks (before, success, error, after, teardown) at both menu and
|
82
|
+
- Full lifecycle hooks (before, success, error, after, teardown) at both menu and
|
83
|
+
command levels
|
82
84
|
- Built-in retry support, spinner visuals, and confirmation prompts
|
83
85
|
- Submenu nesting and action chaining
|
84
86
|
- History tracking, help generation, and run key execution modes
|
@@ -99,12 +101,14 @@ class Falyx:
|
|
99
101
|
force_confirm (bool): Seed default for `OptionsManager["force_confirm"]`
|
100
102
|
cli_args (Namespace | None): Parsed CLI arguments, usually from argparse.
|
101
103
|
options (OptionsManager | None): Declarative option mappings.
|
102
|
-
custom_table (Callable[[Falyx], Table] | Table | None): Custom menu table
|
104
|
+
custom_table (Callable[[Falyx], Table] | Table | None): Custom menu table
|
105
|
+
generator.
|
103
106
|
|
104
107
|
Methods:
|
105
|
-
run(): Main entry point for CLI argument-based workflows.
|
108
|
+
run(): Main entry point for CLI argument-based workflows. Suggested for
|
109
|
+
most use cases.
|
106
110
|
menu(): Run the interactive menu loop.
|
107
|
-
run_key(command_key, return_context): Run a command directly without
|
111
|
+
run_key(command_key, return_context): Run a command directly without the menu.
|
108
112
|
add_command(): Add a single command to the menu.
|
109
113
|
add_commands(): Add multiple commands at once.
|
110
114
|
register_all_hooks(): Register hooks across all commands and submenus.
|
@@ -184,8 +188,10 @@ class Falyx:
|
|
184
188
|
|
185
189
|
@property
|
186
190
|
def _name_map(self) -> dict[str, Command]:
|
187
|
-
"""
|
188
|
-
|
191
|
+
"""
|
192
|
+
Builds a mapping of all valid input names (keys, aliases, normalized names) to
|
193
|
+
Command objects. If a collision occurs, logs a warning and keeps the first
|
194
|
+
registered command.
|
189
195
|
"""
|
190
196
|
mapping: dict[str, Command] = {}
|
191
197
|
|
@@ -195,8 +201,11 @@ class Falyx:
|
|
195
201
|
existing = mapping[norm]
|
196
202
|
if existing is not cmd:
|
197
203
|
logger.warning(
|
198
|
-
|
199
|
-
|
204
|
+
"[alias conflict] '%s' already assigned to '%s'. "
|
205
|
+
"Skipping for '%s'.",
|
206
|
+
name,
|
207
|
+
existing.description,
|
208
|
+
cmd.description,
|
200
209
|
)
|
201
210
|
else:
|
202
211
|
mapping[norm] = cmd
|
@@ -238,7 +247,7 @@ class Falyx:
|
|
238
247
|
key="Y",
|
239
248
|
description="History",
|
240
249
|
aliases=["HISTORY"],
|
241
|
-
action=er.
|
250
|
+
action=Action(name="View Execution History", action=er.summary),
|
242
251
|
style=OneColors.DARK_YELLOW,
|
243
252
|
)
|
244
253
|
|
@@ -283,7 +292,8 @@ class Falyx:
|
|
283
292
|
self.console.print(table, justify="center")
|
284
293
|
if self.mode == FalyxMode.MENU:
|
285
294
|
self.console.print(
|
286
|
-
f"📦 Tip: '[{OneColors.LIGHT_YELLOW}]?[KEY][/]' to preview a command
|
295
|
+
f"📦 Tip: '[{OneColors.LIGHT_YELLOW}]?[KEY][/]' to preview a command "
|
296
|
+
"before running it.\n",
|
287
297
|
justify="center",
|
288
298
|
)
|
289
299
|
|
@@ -346,7 +356,7 @@ class Falyx:
|
|
346
356
|
is_preview, choice = self.get_command(text, from_validate=True)
|
347
357
|
if is_preview and choice is None:
|
348
358
|
return True
|
349
|
-
return
|
359
|
+
return bool(choice)
|
350
360
|
|
351
361
|
return Validator.from_callable(
|
352
362
|
validator,
|
@@ -444,43 +454,10 @@ class Falyx:
|
|
444
454
|
|
445
455
|
def debug_hooks(self) -> None:
|
446
456
|
"""Logs the names of all hooks registered for the menu and its commands."""
|
447
|
-
|
448
|
-
def hook_names(hook_list):
|
449
|
-
return [hook.__name__ for hook in hook_list]
|
450
|
-
|
451
|
-
logger.debug(
|
452
|
-
"Menu-level before hooks: "
|
453
|
-
f"{hook_names(self.hooks._hooks[HookType.BEFORE])}"
|
454
|
-
)
|
455
|
-
logger.debug(
|
456
|
-
f"Menu-level success hooks: {hook_names(self.hooks._hooks[HookType.ON_SUCCESS])}"
|
457
|
-
)
|
458
|
-
logger.debug(
|
459
|
-
f"Menu-level error hooks: {hook_names(self.hooks._hooks[HookType.ON_ERROR])}"
|
460
|
-
)
|
461
|
-
logger.debug(
|
462
|
-
f"Menu-level after hooks: {hook_names(self.hooks._hooks[HookType.AFTER])}"
|
463
|
-
)
|
464
|
-
logger.debug(
|
465
|
-
f"Menu-level on_teardown hooks: {hook_names(self.hooks._hooks[HookType.ON_TEARDOWN])}"
|
466
|
-
)
|
457
|
+
logger.debug("Menu-level hooks:\n%s", str(self.hooks))
|
467
458
|
|
468
459
|
for key, command in self.commands.items():
|
469
|
-
logger.debug(
|
470
|
-
f"[Command '{key}'] before: {hook_names(command.hooks._hooks[HookType.BEFORE])}"
|
471
|
-
)
|
472
|
-
logger.debug(
|
473
|
-
f"[Command '{key}'] success: {hook_names(command.hooks._hooks[HookType.ON_SUCCESS])}"
|
474
|
-
)
|
475
|
-
logger.debug(
|
476
|
-
f"[Command '{key}'] error: {hook_names(command.hooks._hooks[HookType.ON_ERROR])}"
|
477
|
-
)
|
478
|
-
logger.debug(
|
479
|
-
f"[Command '{key}'] after: {hook_names(command.hooks._hooks[HookType.AFTER])}"
|
480
|
-
)
|
481
|
-
logger.debug(
|
482
|
-
f"[Command '{key}'] on_teardown: {hook_names(command.hooks._hooks[HookType.ON_TEARDOWN])}"
|
483
|
-
)
|
460
|
+
logger.debug("[Command '%s'] hooks:\n%s", key, str(command.hooks))
|
484
461
|
|
485
462
|
def is_key_available(self, key: str) -> bool:
|
486
463
|
key = key.upper()
|
@@ -586,7 +563,7 @@ class Falyx:
|
|
586
563
|
action: BaseAction | Callable[[], Any],
|
587
564
|
*,
|
588
565
|
args: tuple = (),
|
589
|
-
kwargs: dict[str, Any] =
|
566
|
+
kwargs: dict[str, Any] | None = None,
|
590
567
|
hidden: bool = False,
|
591
568
|
aliases: list[str] | None = None,
|
592
569
|
help_text: str = "",
|
@@ -619,7 +596,7 @@ class Falyx:
|
|
619
596
|
description=description,
|
620
597
|
action=action,
|
621
598
|
args=args,
|
622
|
-
kwargs=kwargs,
|
599
|
+
kwargs=kwargs if kwargs else {},
|
623
600
|
hidden=hidden,
|
624
601
|
aliases=aliases if aliases else [],
|
625
602
|
help_text=help_text,
|
@@ -665,20 +642,26 @@ class Falyx:
|
|
665
642
|
bottom_row = []
|
666
643
|
if self.history_command:
|
667
644
|
bottom_row.append(
|
668
|
-
f"[{self.history_command.key}] [{self.history_command.style}]
|
645
|
+
f"[{self.history_command.key}] [{self.history_command.style}]"
|
646
|
+
f"{self.history_command.description}"
|
669
647
|
)
|
670
648
|
if self.help_command:
|
671
649
|
bottom_row.append(
|
672
|
-
f"[{self.help_command.key}] [{self.help_command.style}]
|
650
|
+
f"[{self.help_command.key}] [{self.help_command.style}]"
|
651
|
+
f"{self.help_command.description}"
|
673
652
|
)
|
674
653
|
bottom_row.append(
|
675
|
-
f"[{self.exit_command.key}] [{self.exit_command.style}]
|
654
|
+
f"[{self.exit_command.key}] [{self.exit_command.style}]"
|
655
|
+
f"{self.exit_command.description}"
|
676
656
|
)
|
677
657
|
return bottom_row
|
678
658
|
|
679
659
|
def build_default_table(self) -> Table:
|
680
|
-
"""
|
681
|
-
|
660
|
+
"""
|
661
|
+
Build the standard table layout. Developers can subclass or call this
|
662
|
+
in custom tables.
|
663
|
+
"""
|
664
|
+
table = Table(title=self.title, show_header=False, box=box.SIMPLE, expand=True) # type: ignore[arg-type]
|
682
665
|
visible_commands = [item for item in self.commands.items() if not item[1].hidden]
|
683
666
|
for chunk in chunks(visible_commands, self.columns):
|
684
667
|
row = []
|
@@ -708,7 +691,10 @@ class Falyx:
|
|
708
691
|
def get_command(
|
709
692
|
self, choice: str, from_validate=False
|
710
693
|
) -> tuple[bool, Command | None]:
|
711
|
-
"""
|
694
|
+
"""
|
695
|
+
Returns the selected command based on user input.
|
696
|
+
Supports keys, aliases, and abbreviations.
|
697
|
+
"""
|
712
698
|
is_preview, choice = self.parse_preview_command(choice)
|
713
699
|
if is_preview and not choice and self.help_command:
|
714
700
|
is_preview = False
|
@@ -716,7 +702,7 @@ class Falyx:
|
|
716
702
|
elif is_preview and not choice:
|
717
703
|
if not from_validate:
|
718
704
|
self.console.print(
|
719
|
-
f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode.
|
705
|
+
f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode."
|
720
706
|
)
|
721
707
|
return is_preview, None
|
722
708
|
|
@@ -734,7 +720,8 @@ class Falyx:
|
|
734
720
|
if fuzzy_matches:
|
735
721
|
if not from_validate:
|
736
722
|
self.console.print(
|
737
|
-
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'.
|
723
|
+
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'. "
|
724
|
+
"Did you mean:"
|
738
725
|
)
|
739
726
|
for match in fuzzy_matches:
|
740
727
|
cmd = name_map[match]
|
@@ -759,7 +746,7 @@ class Falyx:
|
|
759
746
|
self, selected_command: Command, error: Exception
|
760
747
|
) -> None:
|
761
748
|
"""Handles errors that occur during the action of the selected command."""
|
762
|
-
logger.exception(
|
749
|
+
logger.exception("Error executing '%s': %s", selected_command.description, error)
|
763
750
|
self.console.print(
|
764
751
|
f"[{OneColors.DARK_RED}]An error occurred while executing "
|
765
752
|
f"{selected_command.description}:[/] {error}"
|
@@ -770,27 +757,27 @@ class Falyx:
|
|
770
757
|
choice = await self.prompt_session.prompt_async()
|
771
758
|
is_preview, selected_command = self.get_command(choice)
|
772
759
|
if not selected_command:
|
773
|
-
logger.info(
|
760
|
+
logger.info("Invalid command '%s'.", choice)
|
774
761
|
return True
|
775
762
|
|
776
763
|
if is_preview:
|
777
|
-
logger.info(
|
764
|
+
logger.info("Preview command '%s' selected.", selected_command.key)
|
778
765
|
await selected_command.preview()
|
779
766
|
return True
|
780
767
|
|
781
768
|
if selected_command.requires_input:
|
782
769
|
program = get_program_invocation()
|
783
770
|
self.console.print(
|
784
|
-
f"[{OneColors.LIGHT_YELLOW}]⚠️ Command '{selected_command.key}' requires
|
785
|
-
f"and must be run via [{OneColors.MAGENTA}]'{program} run
|
786
|
-
"with proper piping or arguments.[/]"
|
771
|
+
f"[{OneColors.LIGHT_YELLOW}]⚠️ Command '{selected_command.key}' requires"
|
772
|
+
f" input and must be run via [{OneColors.MAGENTA}]'{program} run"
|
773
|
+
f"'[{OneColors.LIGHT_YELLOW}] with proper piping or arguments.[/]"
|
787
774
|
)
|
788
775
|
return True
|
789
776
|
|
790
777
|
self.last_run_command = selected_command
|
791
778
|
|
792
779
|
if selected_command == self.exit_command:
|
793
|
-
logger.info(
|
780
|
+
logger.info("🔙 Back selected: exiting %s", self.get_title())
|
794
781
|
return False
|
795
782
|
|
796
783
|
context = self._create_context(selected_command)
|
@@ -821,7 +808,7 @@ class Falyx:
|
|
821
808
|
return None
|
822
809
|
|
823
810
|
if is_preview:
|
824
|
-
logger.info(
|
811
|
+
logger.info("Preview command '%s' selected.", selected_command.key)
|
825
812
|
await selected_command.preview()
|
826
813
|
return None
|
827
814
|
|
@@ -840,13 +827,13 @@ class Falyx:
|
|
840
827
|
|
841
828
|
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
842
829
|
logger.info("[run_key] ✅ '%s' complete.", selected_command.description)
|
843
|
-
except (KeyboardInterrupt, EOFError):
|
830
|
+
except (KeyboardInterrupt, EOFError) as error:
|
844
831
|
logger.warning(
|
845
|
-
"[run_key] ⚠️ Interrupted by user: ", selected_command.description
|
832
|
+
"[run_key] ⚠️ Interrupted by user: %s", selected_command.description
|
846
833
|
)
|
847
834
|
raise FalyxError(
|
848
835
|
f"[run_key] ⚠️ '{selected_command.description}' interrupted by user."
|
849
|
-
)
|
836
|
+
) from error
|
850
837
|
except Exception as error:
|
851
838
|
context.exception = error
|
852
839
|
await self.hooks.trigger(HookType.ON_ERROR, context)
|
@@ -885,7 +872,8 @@ class Falyx:
|
|
885
872
|
selected_command.action.set_retry_policy(selected_command.retry_policy)
|
886
873
|
else:
|
887
874
|
logger.warning(
|
888
|
-
|
875
|
+
"[Command:%s] Retry requested, but action is not an Action instance.",
|
876
|
+
selected_command.key,
|
889
877
|
)
|
890
878
|
|
891
879
|
def print_message(self, message: str | Markdown | dict[str, Any]) -> None:
|
@@ -904,7 +892,7 @@ class Falyx:
|
|
904
892
|
|
905
893
|
async def menu(self) -> None:
|
906
894
|
"""Runs the menu and handles user input."""
|
907
|
-
logger.info(
|
895
|
+
logger.info("Running menu: %s", self.get_title())
|
908
896
|
self.debug_hooks()
|
909
897
|
if self.welcome_message:
|
910
898
|
self.print_message(self.welcome_message)
|
@@ -928,7 +916,7 @@ class Falyx:
|
|
928
916
|
except BackSignal:
|
929
917
|
logger.info("BackSignal received.")
|
930
918
|
finally:
|
931
|
-
logger.info(
|
919
|
+
logger.info("Exiting menu: %s", self.get_title())
|
932
920
|
if self.exit_message:
|
933
921
|
self.print_message(self.exit_message)
|
934
922
|
|
@@ -964,7 +952,7 @@ class Falyx:
|
|
964
952
|
_, command = self.get_command(self.cli_args.name)
|
965
953
|
if not command:
|
966
954
|
self.console.print(
|
967
|
-
f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found.
|
955
|
+
f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found."
|
968
956
|
)
|
969
957
|
sys.exit(1)
|
970
958
|
self.console.print(
|
@@ -979,7 +967,7 @@ class Falyx:
|
|
979
967
|
if is_preview:
|
980
968
|
if command is None:
|
981
969
|
sys.exit(1)
|
982
|
-
logger.info(
|
970
|
+
logger.info("Preview command '%s' selected.", command.key)
|
983
971
|
await command.preview()
|
984
972
|
sys.exit(0)
|
985
973
|
if not command:
|
@@ -1004,12 +992,14 @@ class Falyx:
|
|
1004
992
|
]
|
1005
993
|
if not matching:
|
1006
994
|
self.console.print(
|
1007
|
-
f"[{OneColors.LIGHT_YELLOW}]⚠️ No commands found with tag:
|
995
|
+
f"[{OneColors.LIGHT_YELLOW}]⚠️ No commands found with tag: "
|
996
|
+
f"'{self.cli_args.tag}'"
|
1008
997
|
)
|
1009
998
|
sys.exit(1)
|
1010
999
|
|
1011
1000
|
self.console.print(
|
1012
|
-
f"[{OneColors.CYAN_b}]🚀 Running all commands with tag:[/]
|
1001
|
+
f"[{OneColors.CYAN_b}]🚀 Running all commands with tag:[/] "
|
1002
|
+
f"{self.cli_args.tag}"
|
1013
1003
|
)
|
1014
1004
|
for cmd in matching:
|
1015
1005
|
self._set_retry_policy(cmd)
|
falyx/hook_manager.py
CHANGED
@@ -7,7 +7,7 @@ from enum import Enum
|
|
7
7
|
from typing import Awaitable, Callable, Dict, List, Optional, Union
|
8
8
|
|
9
9
|
from falyx.context import ExecutionContext
|
10
|
-
from falyx.
|
10
|
+
from falyx.logger import logger
|
11
11
|
|
12
12
|
Hook = Union[
|
13
13
|
Callable[[ExecutionContext], None], Callable[[ExecutionContext], Awaitable[None]]
|
@@ -34,6 +34,8 @@ class HookType(Enum):
|
|
34
34
|
|
35
35
|
|
36
36
|
class HookManager:
|
37
|
+
"""HookManager"""
|
38
|
+
|
37
39
|
def __init__(self) -> None:
|
38
40
|
self._hooks: Dict[HookType, List[Hook]] = {
|
39
41
|
hook_type: [] for hook_type in HookType
|
@@ -62,8 +64,11 @@ class HookManager:
|
|
62
64
|
hook(context)
|
63
65
|
except Exception as hook_error:
|
64
66
|
logger.warning(
|
65
|
-
|
66
|
-
|
67
|
+
"⚠️ Hook '%s' raised an exception during '%s' for '%s': %s",
|
68
|
+
hook.__name__,
|
69
|
+
hook_type,
|
70
|
+
context.name,
|
71
|
+
hook_error,
|
67
72
|
)
|
68
73
|
|
69
74
|
if hook_type == HookType.ON_ERROR:
|
@@ -71,3 +76,15 @@ class HookManager:
|
|
71
76
|
context.exception, Exception
|
72
77
|
), "Context exception should be set for ON_ERROR hook"
|
73
78
|
raise context.exception from hook_error
|
79
|
+
|
80
|
+
def __str__(self) -> str:
|
81
|
+
"""Return a formatted string of registered hooks grouped by hook type."""
|
82
|
+
|
83
|
+
def format_hook_list(hooks: list[Hook]) -> str:
|
84
|
+
return ", ".join(h.__name__ for h in hooks) if hooks else "—"
|
85
|
+
|
86
|
+
lines = ["<HookManager>"]
|
87
|
+
for hook_type in HookType:
|
88
|
+
hook_list = self._hooks.get(hook_type, [])
|
89
|
+
lines.append(f" {hook_type.value}: {format_hook_list(hook_list)}")
|
90
|
+
return "\n".join(lines)
|
falyx/hooks.py
CHANGED
@@ -5,11 +5,13 @@ from typing import Any, Callable
|
|
5
5
|
|
6
6
|
from falyx.context import ExecutionContext
|
7
7
|
from falyx.exceptions import CircuitBreakerOpen
|
8
|
-
from falyx.
|
9
|
-
from falyx.
|
8
|
+
from falyx.logger import logger
|
9
|
+
from falyx.themes import OneColors
|
10
10
|
|
11
11
|
|
12
12
|
class ResultReporter:
|
13
|
+
"""Reports the success of an action."""
|
14
|
+
|
13
15
|
def __init__(self, formatter: Callable[[Any], str] | None = None):
|
14
16
|
"""
|
15
17
|
Optional result formatter. If not provided, uses repr(result).
|
@@ -41,6 +43,8 @@ class ResultReporter:
|
|
41
43
|
|
42
44
|
|
43
45
|
class CircuitBreaker:
|
46
|
+
"""Circuit Breaker pattern to prevent repeated failures."""
|
47
|
+
|
44
48
|
def __init__(self, max_failures=3, reset_timeout=10):
|
45
49
|
self.max_failures = max_failures
|
46
50
|
self.reset_timeout = reset_timeout
|
@@ -55,7 +59,7 @@ class CircuitBreaker:
|
|
55
59
|
f"🔴 Circuit open for '{name}' until {time.ctime(self.open_until)}."
|
56
60
|
)
|
57
61
|
else:
|
58
|
-
logger.info(
|
62
|
+
logger.info("🟢 Circuit closed again for '%s'.")
|
59
63
|
self.failures = 0
|
60
64
|
self.open_until = None
|
61
65
|
|
@@ -63,15 +67,18 @@ class CircuitBreaker:
|
|
63
67
|
name = context.name
|
64
68
|
self.failures += 1
|
65
69
|
logger.warning(
|
66
|
-
|
70
|
+
"⚠️ CircuitBreaker: '%s' failure %s/%s.",
|
71
|
+
name,
|
72
|
+
self.failures,
|
73
|
+
self.max_failures,
|
67
74
|
)
|
68
75
|
if self.failures >= self.max_failures:
|
69
76
|
self.open_until = time.time() + self.reset_timeout
|
70
77
|
logger.error(
|
71
|
-
|
78
|
+
"🔴 Circuit opened for '%s' until %s.", name, time.ctime(self.open_until)
|
72
79
|
)
|
73
80
|
|
74
|
-
def after_hook(self,
|
81
|
+
def after_hook(self, _: ExecutionContext):
|
75
82
|
self.failures = 0
|
76
83
|
|
77
84
|
def is_open(self):
|
falyx/init.py
CHANGED
falyx/logger.py
ADDED
falyx/menu.py
ADDED
@@ -0,0 +1,85 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from dataclasses import dataclass
|
4
|
+
|
5
|
+
from falyx.action import BaseAction
|
6
|
+
from falyx.signals import BackSignal, QuitSignal
|
7
|
+
from falyx.themes import OneColors
|
8
|
+
from falyx.utils import CaseInsensitiveDict
|
9
|
+
|
10
|
+
|
11
|
+
@dataclass
|
12
|
+
class MenuOption:
|
13
|
+
"""Represents a single menu option with a description and an action to execute."""
|
14
|
+
|
15
|
+
description: str
|
16
|
+
action: BaseAction
|
17
|
+
style: str = OneColors.WHITE
|
18
|
+
|
19
|
+
def __post_init__(self):
|
20
|
+
if not isinstance(self.description, str):
|
21
|
+
raise TypeError("MenuOption description must be a string.")
|
22
|
+
if not isinstance(self.action, BaseAction):
|
23
|
+
raise TypeError("MenuOption action must be a BaseAction instance.")
|
24
|
+
|
25
|
+
def render(self, key: str) -> str:
|
26
|
+
"""Render the menu option for display."""
|
27
|
+
return f"[{OneColors.WHITE}][{key}][/] [{self.style}]{self.description}[/]"
|
28
|
+
|
29
|
+
|
30
|
+
class MenuOptionMap(CaseInsensitiveDict):
|
31
|
+
"""
|
32
|
+
Manages menu options including validation, reserved key protection,
|
33
|
+
and special signal entries like Quit and Back.
|
34
|
+
"""
|
35
|
+
|
36
|
+
RESERVED_KEYS = {"Q", "B"}
|
37
|
+
|
38
|
+
def __init__(
|
39
|
+
self,
|
40
|
+
options: dict[str, MenuOption] | None = None,
|
41
|
+
allow_reserved: bool = False,
|
42
|
+
):
|
43
|
+
super().__init__()
|
44
|
+
self.allow_reserved = allow_reserved
|
45
|
+
if options:
|
46
|
+
self.update(options)
|
47
|
+
self._inject_reserved_defaults()
|
48
|
+
|
49
|
+
def _inject_reserved_defaults(self):
|
50
|
+
from falyx.action import SignalAction
|
51
|
+
|
52
|
+
self._add_reserved(
|
53
|
+
"Q",
|
54
|
+
MenuOption("Exit", SignalAction("Quit", QuitSignal()), OneColors.DARK_RED),
|
55
|
+
)
|
56
|
+
self._add_reserved(
|
57
|
+
"B",
|
58
|
+
MenuOption("Back", SignalAction("Back", BackSignal()), OneColors.DARK_YELLOW),
|
59
|
+
)
|
60
|
+
|
61
|
+
def _add_reserved(self, key: str, option: MenuOption) -> None:
|
62
|
+
"""Add a reserved key, bypassing validation."""
|
63
|
+
norm_key = key.upper()
|
64
|
+
super().__setitem__(norm_key, option)
|
65
|
+
|
66
|
+
def __setitem__(self, key: str, option: MenuOption) -> None:
|
67
|
+
if not isinstance(option, MenuOption):
|
68
|
+
raise TypeError(f"Value for key '{key}' must be a MenuOption.")
|
69
|
+
norm_key = key.upper()
|
70
|
+
if norm_key in self.RESERVED_KEYS and not self.allow_reserved:
|
71
|
+
raise ValueError(
|
72
|
+
f"Key '{key}' is reserved and cannot be used in MenuOptionMap."
|
73
|
+
)
|
74
|
+
super().__setitem__(norm_key, option)
|
75
|
+
|
76
|
+
def __delitem__(self, key: str) -> None:
|
77
|
+
if key.upper() in self.RESERVED_KEYS and not self.allow_reserved:
|
78
|
+
raise ValueError(f"Cannot delete reserved option '{key}'.")
|
79
|
+
super().__delitem__(key)
|
80
|
+
|
81
|
+
def items(self, include_reserved: bool = True):
|
82
|
+
for k, v in super().items():
|
83
|
+
if not include_reserved and k in self.RESERVED_KEYS:
|
84
|
+
continue
|
85
|
+
yield k, v
|
falyx/options_manager.py
CHANGED
@@ -5,12 +5,14 @@ from argparse import Namespace
|
|
5
5
|
from collections import defaultdict
|
6
6
|
from typing import Any, Callable
|
7
7
|
|
8
|
-
from falyx.
|
8
|
+
from falyx.logger import logger
|
9
9
|
|
10
10
|
|
11
11
|
class OptionsManager:
|
12
|
+
"""OptionsManager"""
|
13
|
+
|
12
14
|
def __init__(self, namespaces: list[tuple[str, Namespace]] | None = None) -> None:
|
13
|
-
self.options: defaultdict = defaultdict(
|
15
|
+
self.options: defaultdict = defaultdict(Namespace)
|
14
16
|
if namespaces:
|
15
17
|
for namespace_name, namespace in namespaces:
|
16
18
|
self.from_namespace(namespace, namespace_name)
|
@@ -42,7 +44,9 @@ class OptionsManager:
|
|
42
44
|
f"Cannot toggle non-boolean option: '{option_name}' in '{namespace_name}'"
|
43
45
|
)
|
44
46
|
self.set(option_name, not current, namespace_name=namespace_name)
|
45
|
-
logger.debug(
|
47
|
+
logger.debug(
|
48
|
+
"Toggled '%s' in '%s' to %s", option_name, namespace_name, not current
|
49
|
+
)
|
46
50
|
|
47
51
|
def get_value_getter(
|
48
52
|
self, option_name: str, namespace_name: str = "cli_args"
|
falyx/parsers.py
CHANGED
@@ -39,7 +39,7 @@ def get_arg_parsers(
|
|
39
39
|
epilog: (
|
40
40
|
str | None
|
41
41
|
) = "Tip: Use 'falyx run ?[COMMAND]' to preview any command from the CLI.",
|
42
|
-
parents: Sequence[ArgumentParser] =
|
42
|
+
parents: Sequence[ArgumentParser] | None = None,
|
43
43
|
prefix_chars: str = "-",
|
44
44
|
fromfile_prefix_chars: str | None = None,
|
45
45
|
argument_default: Any = None,
|
@@ -54,7 +54,7 @@ def get_arg_parsers(
|
|
54
54
|
usage=usage,
|
55
55
|
description=description,
|
56
56
|
epilog=epilog,
|
57
|
-
parents=parents,
|
57
|
+
parents=parents if parents else [],
|
58
58
|
prefix_chars=prefix_chars,
|
59
59
|
fromfile_prefix_chars=fromfile_prefix_chars,
|
60
60
|
argument_default=argument_default,
|
falyx/prompt_utils.py
CHANGED
@@ -1,5 +1,15 @@
|
|
1
1
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
2
|
+
"""prompt_utils.py"""
|
3
|
+
from prompt_toolkit import PromptSession
|
4
|
+
from prompt_toolkit.formatted_text import (
|
5
|
+
AnyFormattedText,
|
6
|
+
FormattedText,
|
7
|
+
merge_formatted_text,
|
8
|
+
)
|
9
|
+
|
2
10
|
from falyx.options_manager import OptionsManager
|
11
|
+
from falyx.themes import OneColors
|
12
|
+
from falyx.validators import yes_no_validator
|
3
13
|
|
4
14
|
|
5
15
|
def should_prompt_user(
|
@@ -8,7 +18,10 @@ def should_prompt_user(
|
|
8
18
|
options: OptionsManager,
|
9
19
|
namespace: str = "cli_args",
|
10
20
|
):
|
11
|
-
"""
|
21
|
+
"""
|
22
|
+
Determine whether to prompt the user for confirmation based on command
|
23
|
+
and global options.
|
24
|
+
"""
|
12
25
|
never_prompt = options.get("never_prompt", False, namespace)
|
13
26
|
force_confirm = options.get("force_confirm", False, namespace)
|
14
27
|
skip_confirm = options.get("skip_confirm", False, namespace)
|
@@ -17,3 +30,19 @@ def should_prompt_user(
|
|
17
30
|
return False
|
18
31
|
|
19
32
|
return confirm or force_confirm
|
33
|
+
|
34
|
+
|
35
|
+
async def confirm_async(
|
36
|
+
message: AnyFormattedText = "Are you sure?",
|
37
|
+
prefix: AnyFormattedText = FormattedText([(OneColors.CYAN, "❓ ")]),
|
38
|
+
suffix: AnyFormattedText = FormattedText([(OneColors.LIGHT_YELLOW_b, " [Y/n] > ")]),
|
39
|
+
session: PromptSession | None = None,
|
40
|
+
) -> bool:
|
41
|
+
"""Prompt the user with a yes/no async confirmation and return True for 'Y'."""
|
42
|
+
session = session or PromptSession()
|
43
|
+
merged_message: AnyFormattedText = merge_formatted_text([prefix, message, suffix])
|
44
|
+
answer = await session.prompt_async(
|
45
|
+
merged_message,
|
46
|
+
validator=yes_no_validator(),
|
47
|
+
)
|
48
|
+
return answer.upper() == "Y"
|
falyx/protocols.py
CHANGED
@@ -1,9 +1,10 @@
|
|
1
1
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
2
|
+
"""protocols.py"""
|
2
3
|
from __future__ import annotations
|
3
4
|
|
4
5
|
from typing import Any, Protocol
|
5
6
|
|
6
|
-
from falyx.action import BaseAction
|
7
|
+
from falyx.action.action import BaseAction
|
7
8
|
|
8
9
|
|
9
10
|
class ActionFactoryProtocol(Protocol):
|