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.
Files changed (44) hide show
  1. falyx/__init__.py +1 -1
  2. falyx/action/__init__.py +41 -0
  3. falyx/{action.py → action/action.py} +41 -23
  4. falyx/{action_factory.py → action/action_factory.py} +17 -5
  5. falyx/{http_action.py → action/http_action.py} +10 -9
  6. falyx/{io_action.py → action/io_action.py} +19 -14
  7. falyx/{menu_action.py → action/menu_action.py} +9 -79
  8. falyx/{select_file_action.py → action/select_file_action.py} +5 -36
  9. falyx/{selection_action.py → action/selection_action.py} +22 -8
  10. falyx/action/signal_action.py +43 -0
  11. falyx/action/types.py +37 -0
  12. falyx/bottom_bar.py +3 -3
  13. falyx/command.py +13 -10
  14. falyx/config.py +17 -9
  15. falyx/context.py +16 -8
  16. falyx/debug.py +2 -1
  17. falyx/exceptions.py +3 -0
  18. falyx/execution_registry.py +59 -13
  19. falyx/falyx.py +67 -77
  20. falyx/hook_manager.py +20 -3
  21. falyx/hooks.py +13 -6
  22. falyx/init.py +1 -0
  23. falyx/logger.py +5 -0
  24. falyx/menu.py +85 -0
  25. falyx/options_manager.py +7 -3
  26. falyx/parsers.py +2 -2
  27. falyx/prompt_utils.py +30 -1
  28. falyx/protocols.py +2 -1
  29. falyx/retry.py +23 -12
  30. falyx/retry_utils.py +2 -1
  31. falyx/selection.py +7 -3
  32. falyx/signals.py +3 -0
  33. falyx/tagged_table.py +2 -1
  34. falyx/themes/__init__.py +15 -0
  35. falyx/utils.py +11 -39
  36. falyx/validators.py +8 -7
  37. falyx/version.py +1 -1
  38. {falyx-0.1.23.dist-info → falyx-0.1.25.dist-info}/METADATA +2 -1
  39. falyx-0.1.25.dist-info/RECORD +46 -0
  40. falyx/signal_action.py +0 -30
  41. falyx-0.1.23.dist-info/RECORD +0 -41
  42. {falyx-0.1.23.dist-info → falyx-0.1.25.dist-info}/LICENSE +0 -0
  43. {falyx-0.1.23.dist-info → falyx-0.1.25.dist-info}/WHEEL +0 -0
  44. {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.colors import OneColors, get_nord_theme
59
- from falyx.utils import CaseInsensitiveDict, chunks, get_program_invocation, logger
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 command levels
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 generator.
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. Most users will use this.
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 showing the menu.
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
- """Builds a mapping of all valid input names (keys, aliases, normalized names) to Command objects.
188
- If a collision occurs, logs a warning and keeps the first registered command.
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
- f"[alias conflict] '{name}' already assigned to '{existing.description}'."
199
- f" Skipping for '{cmd.description}'."
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.get_history_action(),
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 before running it.\n",
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 True if choice else False
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}]{self.history_command.description}"
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}]{self.help_command.description}"
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}]{self.exit_command.description}"
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
- """Build the standard table layout. Developers can subclass or call this in custom tables."""
681
- table = Table(title=self.title, show_header=False, box=box.SIMPLE, expand=True)
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
- """Returns the selected command based on user input. Supports keys, aliases, and abbreviations."""
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}'. Did you mean:[/] "
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(f"Error executing '{selected_command.description}': {error}")
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(f"Invalid command '{choice}'.")
760
+ logger.info("Invalid command '%s'.", choice)
774
761
  return True
775
762
 
776
763
  if is_preview:
777
- logger.info(f"Preview command '{selected_command.key}' selected.")
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 input "
785
- f"and must be run via [{OneColors.MAGENTA}]'{program} run'[{OneColors.LIGHT_YELLOW}] "
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(f"🔙 Back selected: exiting {self.get_title()}")
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(f"Preview command '{selected_command.key}' selected.")
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
- f"[Command:{selected_command.key}] Retry requested, but action is not an Action instance."
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(f"Running menu: {self.get_title()}")
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(f"Exiting menu: {self.get_title()}")
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(f"Preview command '{command.key}' selected.")
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: '{self.cli_args.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:[/] {self.cli_args.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.utils import logger
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
- f"⚠️ Hook '{hook.__name__}' raised an exception during '{hook_type}'"
66
- f" for '{context.name}': {hook_error}"
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.themes.colors import OneColors
9
- from falyx.utils import logger
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(f"🟢 Circuit closed again for '{name}'.")
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
- f"⚠️ CircuitBreaker: '{name}' failure {self.failures}/{self.max_failures}."
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
- f"🔴 Circuit opened for '{name}' until {time.ctime(self.open_until)}."
78
+ "🔴 Circuit opened for '%s' until %s.", name, time.ctime(self.open_until)
72
79
  )
73
80
 
74
- def after_hook(self, context: ExecutionContext):
81
+ def after_hook(self, _: ExecutionContext):
75
82
  self.failures = 0
76
83
 
77
84
  def is_open(self):
falyx/init.py CHANGED
@@ -1,4 +1,5 @@
1
1
  # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
+ """init.py"""
2
3
  from pathlib import Path
3
4
 
4
5
  from rich.console import Console
falyx/logger.py ADDED
@@ -0,0 +1,5 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
+ """logger.py"""
3
+ import logging
4
+
5
+ logger = logging.getLogger("falyx")
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.utils import logger
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(lambda: Namespace())
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(f"Toggled '{option_name}' in '{namespace_name}' to {not current}")
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
- """Determine whether to prompt the user for confirmation based on command and global options."""
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):