falyx 0.1.22__py3-none-any.whl → 0.1.24__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/__main__.py +3 -8
- falyx/action.py +40 -22
- falyx/action_factory.py +16 -3
- falyx/bottom_bar.py +2 -2
- falyx/command.py +10 -7
- falyx/config.py +134 -56
- falyx/config_schema.py +76 -0
- falyx/context.py +16 -8
- falyx/debug.py +2 -1
- falyx/exceptions.py +3 -0
- falyx/execution_registry.py +61 -14
- falyx/falyx.py +108 -100
- falyx/hook_manager.py +20 -3
- falyx/hooks.py +12 -5
- falyx/http_action.py +8 -7
- falyx/init.py +83 -22
- falyx/io_action.py +34 -16
- falyx/logger.py +5 -0
- falyx/menu_action.py +8 -2
- falyx/options_manager.py +7 -3
- falyx/parsers.py +2 -2
- falyx/prompt_utils.py +31 -1
- falyx/protocols.py +2 -0
- falyx/retry.py +23 -12
- falyx/retry_utils.py +1 -0
- falyx/select_file_action.py +40 -5
- falyx/selection.py +9 -5
- falyx/selection_action.py +22 -8
- falyx/signal_action.py +2 -0
- falyx/signals.py +3 -0
- falyx/tagged_table.py +2 -1
- falyx/utils.py +11 -39
- falyx/validators.py +8 -7
- falyx/version.py +1 -1
- {falyx-0.1.22.dist-info → falyx-0.1.24.dist-info}/METADATA +1 -1
- falyx-0.1.24.dist-info/RECORD +42 -0
- falyx-0.1.22.dist-info/RECORD +0 -40
- {falyx-0.1.22.dist-info → falyx-0.1.24.dist-info}/LICENSE +0 -0
- {falyx-0.1.22.dist-info → falyx-0.1.24.dist-info}/WHEEL +0 -0
- {falyx-0.1.22.dist-info → falyx-0.1.24.dist-info}/entry_points.txt +0 -0
falyx/falyx.py
CHANGED
@@ -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
59
|
from falyx.themes.colors import OneColors, get_nord_theme
|
59
|
-
from falyx.utils import CaseInsensitiveDict, chunks, get_program_invocation
|
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
|
|
@@ -291,7 +301,7 @@ class Falyx:
|
|
291
301
|
"""Returns the help command for the menu."""
|
292
302
|
return Command(
|
293
303
|
key="H",
|
294
|
-
aliases=["HELP"],
|
304
|
+
aliases=["HELP", "?"],
|
295
305
|
description="Help",
|
296
306
|
action=self._show_help,
|
297
307
|
style=OneColors.LIGHT_YELLOW,
|
@@ -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()
|
@@ -560,10 +537,24 @@ class Falyx:
|
|
560
537
|
self.add_command(key, description, submenu.menu, style=style)
|
561
538
|
submenu.update_exit_command(key="B", description="Back", aliases=["BACK"])
|
562
539
|
|
563
|
-
def add_commands(self, commands: list[dict]) -> None:
|
564
|
-
"""Adds
|
540
|
+
def add_commands(self, commands: list[Command] | list[dict]) -> None:
|
541
|
+
"""Adds a list of Command instances or config dicts."""
|
565
542
|
for command in commands:
|
566
|
-
|
543
|
+
if isinstance(command, dict):
|
544
|
+
self.add_command(**command)
|
545
|
+
elif isinstance(command, Command):
|
546
|
+
self.add_command_from_command(command)
|
547
|
+
else:
|
548
|
+
raise FalyxError(
|
549
|
+
"Command must be a dictionary or an instance of Command."
|
550
|
+
)
|
551
|
+
|
552
|
+
def add_command_from_command(self, command: Command) -> None:
|
553
|
+
"""Adds a command to the menu from an existing Command object."""
|
554
|
+
if not isinstance(command, Command):
|
555
|
+
raise FalyxError("command must be an instance of Command.")
|
556
|
+
self._validate_command_key(command.key)
|
557
|
+
self.commands[command.key] = command
|
567
558
|
|
568
559
|
def add_command(
|
569
560
|
self,
|
@@ -572,7 +563,7 @@ class Falyx:
|
|
572
563
|
action: BaseAction | Callable[[], Any],
|
573
564
|
*,
|
574
565
|
args: tuple = (),
|
575
|
-
kwargs: dict[str, Any] =
|
566
|
+
kwargs: dict[str, Any] | None = None,
|
576
567
|
hidden: bool = False,
|
577
568
|
aliases: list[str] | None = None,
|
578
569
|
help_text: str = "",
|
@@ -605,7 +596,7 @@ class Falyx:
|
|
605
596
|
description=description,
|
606
597
|
action=action,
|
607
598
|
args=args,
|
608
|
-
kwargs=kwargs,
|
599
|
+
kwargs=kwargs if kwargs else {},
|
609
600
|
hidden=hidden,
|
610
601
|
aliases=aliases if aliases else [],
|
611
602
|
help_text=help_text,
|
@@ -651,20 +642,26 @@ class Falyx:
|
|
651
642
|
bottom_row = []
|
652
643
|
if self.history_command:
|
653
644
|
bottom_row.append(
|
654
|
-
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}"
|
655
647
|
)
|
656
648
|
if self.help_command:
|
657
649
|
bottom_row.append(
|
658
|
-
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}"
|
659
652
|
)
|
660
653
|
bottom_row.append(
|
661
|
-
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}"
|
662
656
|
)
|
663
657
|
return bottom_row
|
664
658
|
|
665
659
|
def build_default_table(self) -> Table:
|
666
|
-
"""
|
667
|
-
|
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]
|
668
665
|
visible_commands = [item for item in self.commands.items() if not item[1].hidden]
|
669
666
|
for chunk in chunks(visible_commands, self.columns):
|
670
667
|
row = []
|
@@ -694,12 +691,18 @@ class Falyx:
|
|
694
691
|
def get_command(
|
695
692
|
self, choice: str, from_validate=False
|
696
693
|
) -> tuple[bool, Command | None]:
|
697
|
-
"""
|
694
|
+
"""
|
695
|
+
Returns the selected command based on user input.
|
696
|
+
Supports keys, aliases, and abbreviations.
|
697
|
+
"""
|
698
698
|
is_preview, choice = self.parse_preview_command(choice)
|
699
|
-
if is_preview and not choice:
|
699
|
+
if is_preview and not choice and self.help_command:
|
700
|
+
is_preview = False
|
701
|
+
choice = "?"
|
702
|
+
elif is_preview and not choice:
|
700
703
|
if not from_validate:
|
701
704
|
self.console.print(
|
702
|
-
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."
|
703
706
|
)
|
704
707
|
return is_preview, None
|
705
708
|
|
@@ -717,7 +720,8 @@ class Falyx:
|
|
717
720
|
if fuzzy_matches:
|
718
721
|
if not from_validate:
|
719
722
|
self.console.print(
|
720
|
-
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'.
|
723
|
+
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'. "
|
724
|
+
"Did you mean:"
|
721
725
|
)
|
722
726
|
for match in fuzzy_matches:
|
723
727
|
cmd = name_map[match]
|
@@ -742,7 +746,7 @@ class Falyx:
|
|
742
746
|
self, selected_command: Command, error: Exception
|
743
747
|
) -> None:
|
744
748
|
"""Handles errors that occur during the action of the selected command."""
|
745
|
-
logger.exception(
|
749
|
+
logger.exception("Error executing '%s': %s", selected_command.description, error)
|
746
750
|
self.console.print(
|
747
751
|
f"[{OneColors.DARK_RED}]An error occurred while executing "
|
748
752
|
f"{selected_command.description}:[/] {error}"
|
@@ -753,27 +757,27 @@ class Falyx:
|
|
753
757
|
choice = await self.prompt_session.prompt_async()
|
754
758
|
is_preview, selected_command = self.get_command(choice)
|
755
759
|
if not selected_command:
|
756
|
-
logger.info(
|
760
|
+
logger.info("Invalid command '%s'.", choice)
|
757
761
|
return True
|
758
762
|
|
759
763
|
if is_preview:
|
760
|
-
logger.info(
|
764
|
+
logger.info("Preview command '%s' selected.", selected_command.key)
|
761
765
|
await selected_command.preview()
|
762
766
|
return True
|
763
767
|
|
764
768
|
if selected_command.requires_input:
|
765
769
|
program = get_program_invocation()
|
766
770
|
self.console.print(
|
767
|
-
f"[{OneColors.LIGHT_YELLOW}]⚠️ Command '{selected_command.key}' requires
|
768
|
-
f"and must be run via [{OneColors.MAGENTA}]'{program} run
|
769
|
-
"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.[/]"
|
770
774
|
)
|
771
775
|
return True
|
772
776
|
|
773
777
|
self.last_run_command = selected_command
|
774
778
|
|
775
779
|
if selected_command == self.exit_command:
|
776
|
-
logger.info(
|
780
|
+
logger.info("🔙 Back selected: exiting %s", self.get_title())
|
777
781
|
return False
|
778
782
|
|
779
783
|
context = self._create_context(selected_command)
|
@@ -804,7 +808,7 @@ class Falyx:
|
|
804
808
|
return None
|
805
809
|
|
806
810
|
if is_preview:
|
807
|
-
logger.info(
|
811
|
+
logger.info("Preview command '%s' selected.", selected_command.key)
|
808
812
|
await selected_command.preview()
|
809
813
|
return None
|
810
814
|
|
@@ -823,13 +827,13 @@ class Falyx:
|
|
823
827
|
|
824
828
|
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
825
829
|
logger.info("[run_key] ✅ '%s' complete.", selected_command.description)
|
826
|
-
except (KeyboardInterrupt, EOFError):
|
830
|
+
except (KeyboardInterrupt, EOFError) as error:
|
827
831
|
logger.warning(
|
828
|
-
"[run_key] ⚠️ Interrupted by user: ", selected_command.description
|
832
|
+
"[run_key] ⚠️ Interrupted by user: %s", selected_command.description
|
829
833
|
)
|
830
834
|
raise FalyxError(
|
831
835
|
f"[run_key] ⚠️ '{selected_command.description}' interrupted by user."
|
832
|
-
)
|
836
|
+
) from error
|
833
837
|
except Exception as error:
|
834
838
|
context.exception = error
|
835
839
|
await self.hooks.trigger(HookType.ON_ERROR, context)
|
@@ -868,7 +872,8 @@ class Falyx:
|
|
868
872
|
selected_command.action.set_retry_policy(selected_command.retry_policy)
|
869
873
|
else:
|
870
874
|
logger.warning(
|
871
|
-
|
875
|
+
"[Command:%s] Retry requested, but action is not an Action instance.",
|
876
|
+
selected_command.key,
|
872
877
|
)
|
873
878
|
|
874
879
|
def print_message(self, message: str | Markdown | dict[str, Any]) -> None:
|
@@ -887,32 +892,33 @@ class Falyx:
|
|
887
892
|
|
888
893
|
async def menu(self) -> None:
|
889
894
|
"""Runs the menu and handles user input."""
|
890
|
-
logger.info(
|
895
|
+
logger.info("Running menu: %s", self.get_title())
|
891
896
|
self.debug_hooks()
|
892
897
|
if self.welcome_message:
|
893
898
|
self.print_message(self.welcome_message)
|
894
|
-
|
895
|
-
|
896
|
-
self.render_menu
|
897
|
-
|
898
|
-
|
899
|
-
|
900
|
-
|
901
|
-
|
902
|
-
|
899
|
+
try:
|
900
|
+
while True:
|
901
|
+
if callable(self.render_menu):
|
902
|
+
self.render_menu(self)
|
903
|
+
else:
|
904
|
+
self.console.print(self.table, justify="center")
|
905
|
+
try:
|
906
|
+
task = asyncio.create_task(self.process_command())
|
907
|
+
should_continue = await task
|
908
|
+
if not should_continue:
|
909
|
+
break
|
910
|
+
except (EOFError, KeyboardInterrupt):
|
911
|
+
logger.info("EOF or KeyboardInterrupt. Exiting menu.")
|
912
|
+
break
|
913
|
+
except QuitSignal:
|
914
|
+
logger.info("QuitSignal received. Exiting menu.")
|
903
915
|
break
|
904
|
-
|
905
|
-
|
906
|
-
|
907
|
-
|
908
|
-
|
909
|
-
|
910
|
-
except BackSignal:
|
911
|
-
logger.info("BackSignal received.")
|
912
|
-
finally:
|
913
|
-
logger.info(f"Exiting menu: {self.get_title()}")
|
914
|
-
if self.exit_message:
|
915
|
-
self.print_message(self.exit_message)
|
916
|
+
except BackSignal:
|
917
|
+
logger.info("BackSignal received.")
|
918
|
+
finally:
|
919
|
+
logger.info("Exiting menu: %s", self.get_title())
|
920
|
+
if self.exit_message:
|
921
|
+
self.print_message(self.exit_message)
|
916
922
|
|
917
923
|
async def run(self) -> None:
|
918
924
|
"""Run Falyx CLI with structured subcommands."""
|
@@ -946,7 +952,7 @@ class Falyx:
|
|
946
952
|
_, command = self.get_command(self.cli_args.name)
|
947
953
|
if not command:
|
948
954
|
self.console.print(
|
949
|
-
f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found.
|
955
|
+
f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found."
|
950
956
|
)
|
951
957
|
sys.exit(1)
|
952
958
|
self.console.print(
|
@@ -961,7 +967,7 @@ class Falyx:
|
|
961
967
|
if is_preview:
|
962
968
|
if command is None:
|
963
969
|
sys.exit(1)
|
964
|
-
logger.info(
|
970
|
+
logger.info("Preview command '%s' selected.", command.key)
|
965
971
|
await command.preview()
|
966
972
|
sys.exit(0)
|
967
973
|
if not command:
|
@@ -986,12 +992,14 @@ class Falyx:
|
|
986
992
|
]
|
987
993
|
if not matching:
|
988
994
|
self.console.print(
|
989
|
-
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}'"
|
990
997
|
)
|
991
998
|
sys.exit(1)
|
992
999
|
|
993
1000
|
self.console.print(
|
994
|
-
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}"
|
995
1003
|
)
|
996
1004
|
for cmd in matching:
|
997
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.logger import logger
|
8
9
|
from falyx.themes.colors import OneColors
|
9
|
-
from falyx.utils import logger
|
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/http_action.py
CHANGED
@@ -16,8 +16,8 @@ from rich.tree import Tree
|
|
16
16
|
from falyx.action import Action
|
17
17
|
from falyx.context import ExecutionContext, SharedContext
|
18
18
|
from falyx.hook_manager import HookManager, HookType
|
19
|
+
from falyx.logger import logger
|
19
20
|
from falyx.themes.colors import OneColors
|
20
|
-
from falyx.utils import logger
|
21
21
|
|
22
22
|
|
23
23
|
async def close_shared_http_session(context: ExecutionContext) -> None:
|
@@ -35,9 +35,9 @@ class HTTPAction(Action):
|
|
35
35
|
"""
|
36
36
|
An Action for executing HTTP requests using aiohttp with shared session reuse.
|
37
37
|
|
38
|
-
This action integrates seamlessly into Falyx pipelines, with automatic session
|
39
|
-
result injection, and lifecycle hook support. It is ideal for CLI-driven
|
40
|
-
where you need to call remote services and process their responses.
|
38
|
+
This action integrates seamlessly into Falyx pipelines, with automatic session
|
39
|
+
management, result injection, and lifecycle hook support. It is ideal for CLI-driven
|
40
|
+
API workflows where you need to call remote services and process their responses.
|
41
41
|
|
42
42
|
Features:
|
43
43
|
- Uses aiohttp for asynchronous HTTP requests
|
@@ -97,7 +97,7 @@ class HTTPAction(Action):
|
|
97
97
|
retry_policy=retry_policy,
|
98
98
|
)
|
99
99
|
|
100
|
-
async def _request(self, *
|
100
|
+
async def _request(self, *_, **__) -> dict[str, Any]:
|
101
101
|
if self.shared_context:
|
102
102
|
context: SharedContext = self.shared_context
|
103
103
|
session = context.get("http_session")
|
@@ -153,6 +153,7 @@ class HTTPAction(Action):
|
|
153
153
|
def __str__(self):
|
154
154
|
return (
|
155
155
|
f"HTTPAction(name={self.name!r}, method={self.method!r}, url={self.url!r}, "
|
156
|
-
f"headers={self.headers!r}, params={self.params!r}, json={self.json!r},
|
157
|
-
f"
|
156
|
+
f"headers={self.headers!r}, params={self.params!r}, json={self.json!r}, "
|
157
|
+
f"data={self.data!r}, retry={self.retry_policy.enabled}, "
|
158
|
+
f"inject_last_result={self.inject_last_result})"
|
158
159
|
)
|