falyx 0.1.20__tar.gz → 0.1.22__tar.gz

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 (41) hide show
  1. {falyx-0.1.20 → falyx-0.1.22}/PKG-INFO +1 -1
  2. {falyx-0.1.20 → falyx-0.1.22}/falyx/__main__.py +2 -0
  3. {falyx-0.1.20 → falyx-0.1.22}/falyx/action.py +33 -8
  4. {falyx-0.1.20 → falyx-0.1.22}/falyx/bottom_bar.py +1 -1
  5. {falyx-0.1.20 → falyx-0.1.22}/falyx/command.py +7 -1
  6. {falyx-0.1.20 → falyx-0.1.22}/falyx/config.py +28 -3
  7. {falyx-0.1.20 → falyx-0.1.22}/falyx/execution_registry.py +1 -1
  8. {falyx-0.1.20 → falyx-0.1.22}/falyx/falyx.py +35 -10
  9. {falyx-0.1.20 → falyx-0.1.22}/falyx/io_action.py +3 -52
  10. {falyx-0.1.20 → falyx-0.1.22}/falyx/menu_action.py +3 -1
  11. {falyx-0.1.20 → falyx-0.1.22}/falyx/parsers.py +13 -1
  12. falyx-0.1.22/falyx/select_file_action.py +195 -0
  13. {falyx-0.1.20 → falyx-0.1.22}/falyx/selection.py +69 -59
  14. {falyx-0.1.20 → falyx-0.1.22}/falyx/selection_action.py +4 -3
  15. falyx-0.1.22/falyx/version.py +1 -0
  16. {falyx-0.1.20 → falyx-0.1.22}/pyproject.toml +1 -1
  17. falyx-0.1.20/falyx/select_files_action.py +0 -68
  18. falyx-0.1.20/falyx/version.py +0 -1
  19. {falyx-0.1.20 → falyx-0.1.22}/LICENSE +0 -0
  20. {falyx-0.1.20 → falyx-0.1.22}/README.md +0 -0
  21. {falyx-0.1.20 → falyx-0.1.22}/falyx/.pytyped +0 -0
  22. {falyx-0.1.20 → falyx-0.1.22}/falyx/__init__.py +0 -0
  23. {falyx-0.1.20 → falyx-0.1.22}/falyx/action_factory.py +0 -0
  24. {falyx-0.1.20 → falyx-0.1.22}/falyx/context.py +0 -0
  25. {falyx-0.1.20 → falyx-0.1.22}/falyx/debug.py +0 -0
  26. {falyx-0.1.20 → falyx-0.1.22}/falyx/exceptions.py +0 -0
  27. {falyx-0.1.20 → falyx-0.1.22}/falyx/hook_manager.py +0 -0
  28. {falyx-0.1.20 → falyx-0.1.22}/falyx/hooks.py +0 -0
  29. {falyx-0.1.20 → falyx-0.1.22}/falyx/http_action.py +0 -0
  30. {falyx-0.1.20 → falyx-0.1.22}/falyx/init.py +0 -0
  31. {falyx-0.1.20 → falyx-0.1.22}/falyx/options_manager.py +0 -0
  32. {falyx-0.1.20 → falyx-0.1.22}/falyx/prompt_utils.py +0 -0
  33. {falyx-0.1.20 → falyx-0.1.22}/falyx/protocols.py +0 -0
  34. {falyx-0.1.20 → falyx-0.1.22}/falyx/retry.py +0 -0
  35. {falyx-0.1.20 → falyx-0.1.22}/falyx/retry_utils.py +0 -0
  36. {falyx-0.1.20 → falyx-0.1.22}/falyx/signal_action.py +0 -0
  37. {falyx-0.1.20 → falyx-0.1.22}/falyx/signals.py +0 -0
  38. {falyx-0.1.20 → falyx-0.1.22}/falyx/tagged_table.py +0 -0
  39. {falyx-0.1.20 → falyx-0.1.22}/falyx/themes/colors.py +0 -0
  40. {falyx-0.1.20 → falyx-0.1.22}/falyx/utils.py +0 -0
  41. {falyx-0.1.20 → falyx-0.1.22}/falyx/validators.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: falyx
3
- Version: 0.1.20
3
+ Version: 0.1.22
4
4
  Summary: Reliable and introspectable async CLI action framework.
5
5
  License: MIT
6
6
  Author: Roland Thomas Jr
@@ -14,6 +14,7 @@ from typing import Any
14
14
  from falyx.config import loader
15
15
  from falyx.falyx import Falyx
16
16
  from falyx.parsers import FalyxParsers, get_arg_parsers
17
+ from falyx.themes.colors import OneColors
17
18
 
18
19
 
19
20
  def find_falyx_config() -> Path | None:
@@ -71,6 +72,7 @@ def run(args: Namespace) -> Any:
71
72
  title="🛠️ Config-Driven CLI",
72
73
  cli_args=args,
73
74
  columns=4,
75
+ prompt=[(OneColors.BLUE_b, "FALYX > ")],
74
76
  )
75
77
  flx.add_commands(loader(bootstrap_path))
76
78
  return asyncio.run(flx.run())
@@ -64,6 +64,7 @@ class BaseAction(ABC):
64
64
  def __init__(
65
65
  self,
66
66
  name: str,
67
+ *,
67
68
  hooks: HookManager | None = None,
68
69
  inject_last_result: bool = False,
69
70
  inject_into: str = "last_result",
@@ -182,6 +183,7 @@ class Action(BaseAction):
182
183
  self,
183
184
  name: str,
184
185
  action: Callable[..., Any],
186
+ *,
185
187
  rollback: Callable[..., Any] | None = None,
186
188
  args: tuple[Any, ...] = (),
187
189
  kwargs: dict[str, Any] | None = None,
@@ -191,7 +193,12 @@ class Action(BaseAction):
191
193
  retry: bool = False,
192
194
  retry_policy: RetryPolicy | None = None,
193
195
  ) -> None:
194
- super().__init__(name, hooks, inject_last_result, inject_into)
196
+ super().__init__(
197
+ name,
198
+ hooks=hooks,
199
+ inject_last_result=inject_last_result,
200
+ inject_into=inject_into,
201
+ )
195
202
  self.action = action
196
203
  self.rollback = rollback
197
204
  self.args = args
@@ -422,13 +429,19 @@ class ChainedAction(BaseAction, ActionListMixin):
422
429
  self,
423
430
  name: str,
424
431
  actions: list[BaseAction | Any] | None = None,
432
+ *,
425
433
  hooks: HookManager | None = None,
426
434
  inject_last_result: bool = False,
427
435
  inject_into: str = "last_result",
428
436
  auto_inject: bool = False,
429
437
  return_list: bool = False,
430
438
  ) -> None:
431
- super().__init__(name, hooks, inject_last_result, inject_into)
439
+ super().__init__(
440
+ name,
441
+ hooks=hooks,
442
+ inject_last_result=inject_last_result,
443
+ inject_into=inject_into,
444
+ )
432
445
  ActionListMixin.__init__(self)
433
446
  self.auto_inject = auto_inject
434
447
  self.return_list = return_list
@@ -608,11 +621,17 @@ class ActionGroup(BaseAction, ActionListMixin):
608
621
  self,
609
622
  name: str,
610
623
  actions: list[BaseAction] | None = None,
624
+ *,
611
625
  hooks: HookManager | None = None,
612
626
  inject_last_result: bool = False,
613
627
  inject_into: str = "last_result",
614
628
  ):
615
- super().__init__(name, hooks, inject_last_result, inject_into)
629
+ super().__init__(
630
+ name,
631
+ hooks=hooks,
632
+ inject_last_result=inject_last_result,
633
+ inject_into=inject_into,
634
+ )
616
635
  ActionListMixin.__init__(self)
617
636
  if actions:
618
637
  self.set_actions(actions)
@@ -730,7 +749,8 @@ class ProcessAction(BaseAction):
730
749
  def __init__(
731
750
  self,
732
751
  name: str,
733
- func: Callable[..., Any],
752
+ action: Callable[..., Any],
753
+ *,
734
754
  args: tuple = (),
735
755
  kwargs: dict[str, Any] | None = None,
736
756
  hooks: HookManager | None = None,
@@ -738,8 +758,13 @@ class ProcessAction(BaseAction):
738
758
  inject_last_result: bool = False,
739
759
  inject_into: str = "last_result",
740
760
  ):
741
- super().__init__(name, hooks, inject_last_result, inject_into)
742
- self.func = func
761
+ super().__init__(
762
+ name,
763
+ hooks=hooks,
764
+ inject_last_result=inject_last_result,
765
+ inject_into=inject_into,
766
+ )
767
+ self.action = action
743
768
  self.args = args
744
769
  self.kwargs = kwargs or {}
745
770
  self.executor = executor or ProcessPoolExecutor()
@@ -767,7 +792,7 @@ class ProcessAction(BaseAction):
767
792
  try:
768
793
  await self.hooks.trigger(HookType.BEFORE, context)
769
794
  result = await loop.run_in_executor(
770
- self.executor, partial(self.func, *combined_args, **combined_kwargs)
795
+ self.executor, partial(self.action, *combined_args, **combined_kwargs)
771
796
  )
772
797
  context.result = result
773
798
  await self.hooks.trigger(HookType.ON_SUCCESS, context)
@@ -806,6 +831,6 @@ class ProcessAction(BaseAction):
806
831
 
807
832
  def __str__(self) -> str:
808
833
  return (
809
- f"ProcessAction(name={self.name!r}, func={getattr(self.func, '__name__', repr(self.func))}, "
834
+ f"ProcessAction(name={self.name!r}, action={getattr(self.action, '__name__', repr(self.action))}, "
810
835
  f"args={self.args!r}, kwargs={self.kwargs!r})"
811
836
  )
@@ -30,7 +30,7 @@ class BottomBar:
30
30
  key_validator: Callable[[str], bool] | None = None,
31
31
  ) -> None:
32
32
  self.columns = columns
33
- self.console = Console()
33
+ self.console = Console(color_system="auto")
34
34
  self._named_items: dict[str, Callable[[], HTML]] = {}
35
35
  self._value_getters: dict[str, Callable[[], Any]] = CaseInsensitiveDict()
36
36
  self.toggle_keys: list[str] = []
@@ -40,7 +40,7 @@ from falyx.retry_utils import enable_retries_recursively
40
40
  from falyx.themes.colors import OneColors
41
41
  from falyx.utils import _noop, confirm_async, ensure_async, logger
42
42
 
43
- console = Console()
43
+ console = Console(color_system="auto")
44
44
 
45
45
 
46
46
  class Command(BaseModel):
@@ -272,15 +272,21 @@ class Command(BaseModel):
272
272
  if hasattr(self.action, "preview") and callable(self.action.preview):
273
273
  tree = Tree(label)
274
274
  await self.action.preview(parent=tree)
275
+ if self.help_text:
276
+ tree.add(f"[dim]💡 {self.help_text}[/dim]")
275
277
  console.print(tree)
276
278
  elif callable(self.action) and not isinstance(self.action, BaseAction):
277
279
  console.print(f"{label}")
280
+ if self.help_text:
281
+ console.print(f"[dim]💡 {self.help_text}[/dim]")
278
282
  console.print(
279
283
  f"[{OneColors.LIGHT_RED_b}]→ Would call:[/] {self.action.__name__}"
280
284
  f"[dim](args={self.args}, kwargs={self.kwargs})[/dim]"
281
285
  )
282
286
  else:
283
287
  console.print(f"{label}")
288
+ if self.help_text:
289
+ console.print(f"[dim]💡 {self.help_text}[/dim]")
284
290
  console.print(
285
291
  f"[{OneColors.DARK_RED}]⚠️ Action is not callable or lacks a preview method.[/]"
286
292
  )
@@ -3,15 +3,21 @@
3
3
  Configuration loader for Falyx CLI commands."""
4
4
 
5
5
  import importlib
6
+ import sys
6
7
  from pathlib import Path
7
8
  from typing import Any
8
9
 
9
10
  import toml
10
11
  import yaml
12
+ from rich.console import Console
11
13
 
12
14
  from falyx.action import Action, BaseAction
13
15
  from falyx.command import Command
14
16
  from falyx.retry import RetryPolicy
17
+ from falyx.themes.colors import OneColors
18
+ from falyx.utils import logger
19
+
20
+ console = Console(color_system="auto")
15
21
 
16
22
 
17
23
  def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command:
@@ -30,9 +36,28 @@ def import_action(dotted_path: str) -> Any:
30
36
  """Dynamically imports a callable from a dotted path like 'my.module.func'."""
31
37
  module_path, _, attr = dotted_path.rpartition(".")
32
38
  if not module_path:
33
- raise ValueError(f"Invalid action path: {dotted_path}")
34
- module = importlib.import_module(module_path)
35
- return getattr(module, attr)
39
+ console.print(f"[{OneColors.DARK_RED}]❌ Invalid action path:[/] {dotted_path}")
40
+ sys.exit(1)
41
+ try:
42
+ module = importlib.import_module(module_path)
43
+ except ModuleNotFoundError as error:
44
+ logger.error("Failed to import module '%s': %s", module_path, error)
45
+ console.print(
46
+ f"[{OneColors.DARK_RED}]❌ Could not import '{dotted_path}': {error}[/]\n"
47
+ f"[{OneColors.COMMENT_GREY}]Ensure the module is installed and discoverable via PYTHONPATH."
48
+ )
49
+ sys.exit(1)
50
+ try:
51
+ action = getattr(module, attr)
52
+ except AttributeError as error:
53
+ logger.error(
54
+ "Module '%s' does not have attribute '%s': %s", module_path, attr, error
55
+ )
56
+ console.print(
57
+ f"[{OneColors.DARK_RED}]❌ Module '{module_path}' has no attribute '{attr}': {error}[/]"
58
+ )
59
+ sys.exit(1)
60
+ return action
36
61
 
37
62
 
38
63
  def loader(file_path: Path | str) -> list[dict[str, Any]]:
@@ -15,7 +15,7 @@ from falyx.utils import logger
15
15
  class ExecutionRegistry:
16
16
  _store_by_name: Dict[str, List[ExecutionContext]] = defaultdict(list)
17
17
  _store_all: List[ExecutionContext] = []
18
- _console = Console(color_system="truecolor")
18
+ _console = Console(color_system="auto")
19
19
 
20
20
  @classmethod
21
21
  def record(cls, context: ExecutionContext):
@@ -110,12 +110,12 @@ class Falyx:
110
110
  register_all_hooks(): Register hooks across all commands and submenus.
111
111
  debug_hooks(): Log hook registration for debugging.
112
112
  build_default_table(): Construct the standard Rich table layout.
113
-
114
113
  """
115
114
 
116
115
  def __init__(
117
116
  self,
118
117
  title: str | Markdown = "Menu",
118
+ *,
119
119
  prompt: str | AnyFormattedText = "> ",
120
120
  columns: int = 3,
121
121
  bottom_bar: BottomBar | str | Callable[[], Any] | None = None,
@@ -143,7 +143,7 @@ class Falyx:
143
143
  self.help_command: Command | None = (
144
144
  self._get_help_command() if include_help_command else None
145
145
  )
146
- self.console: Console = Console(color_system="truecolor", theme=get_nord_theme())
146
+ self.console: Console = Console(color_system="auto", theme=get_nord_theme())
147
147
  self.welcome_message: str | Markdown | dict[str, Any] = welcome_message
148
148
  self.exit_message: str | Markdown | dict[str, Any] = exit_message
149
149
  self.hooks: HookManager = HookManager()
@@ -283,7 +283,7 @@ class Falyx:
283
283
  self.console.print(table, justify="center")
284
284
  if self.mode == FalyxMode.MENU:
285
285
  self.console.print(
286
- f"📦 Tip: Type '[{OneColors.LIGHT_YELLOW}]?[KEY][/]' to preview a command before running it.\n",
286
+ f"📦 Tip: '[{OneColors.LIGHT_YELLOW}]?[KEY][/]' to preview a command before running it.\n",
287
287
  justify="center",
288
288
  )
289
289
 
@@ -343,7 +343,9 @@ class Falyx:
343
343
  error_message = " ".join(message_lines)
344
344
 
345
345
  def validator(text):
346
- _, choice = self.get_command(text, from_validate=True)
346
+ is_preview, choice = self.get_command(text, from_validate=True)
347
+ if is_preview and choice is None:
348
+ return True
347
349
  return True if choice else False
348
350
 
349
351
  return Validator.from_callable(
@@ -549,7 +551,7 @@ class Falyx:
549
551
  )
550
552
 
551
553
  def add_submenu(
552
- self, key: str, description: str, submenu: "Falyx", style: str = OneColors.CYAN
554
+ self, key: str, description: str, submenu: "Falyx", *, style: str = OneColors.CYAN
553
555
  ) -> None:
554
556
  """Adds a submenu to the menu."""
555
557
  if not isinstance(submenu, Falyx):
@@ -568,6 +570,7 @@ class Falyx:
568
570
  key: str,
569
571
  description: str,
570
572
  action: BaseAction | Callable[[], Any],
573
+ *,
571
574
  args: tuple = (),
572
575
  kwargs: dict[str, Any] = {},
573
576
  hidden: bool = False,
@@ -693,6 +696,13 @@ class Falyx:
693
696
  ) -> tuple[bool, Command | None]:
694
697
  """Returns the selected command based on user input. Supports keys, aliases, and abbreviations."""
695
698
  is_preview, choice = self.parse_preview_command(choice)
699
+ if is_preview and not choice:
700
+ if not from_validate:
701
+ self.console.print(
702
+ f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode.[/]"
703
+ )
704
+ return is_preview, None
705
+
696
706
  choice = choice.upper()
697
707
  name_map = self._name_map
698
708
 
@@ -787,12 +797,17 @@ class Falyx:
787
797
  async def run_key(self, command_key: str, return_context: bool = False) -> Any:
788
798
  """Run a command by key without displaying the menu (non-interactive mode)."""
789
799
  self.debug_hooks()
790
- _, selected_command = self.get_command(command_key)
800
+ is_preview, selected_command = self.get_command(command_key)
791
801
  self.last_run_command = selected_command
792
802
 
793
803
  if not selected_command:
794
804
  return None
795
805
 
806
+ if is_preview:
807
+ logger.info(f"Preview command '{selected_command.key}' selected.")
808
+ await selected_command.preview()
809
+ return None
810
+
796
811
  logger.info(
797
812
  "[run_key] 🚀 Executing: %s — %s",
798
813
  selected_command.key,
@@ -942,11 +957,14 @@ class Falyx:
942
957
 
943
958
  if self.cli_args.command == "run":
944
959
  self.mode = FalyxMode.RUN
945
- _, command = self.get_command(self.cli_args.name)
960
+ is_preview, command = self.get_command(self.cli_args.name)
961
+ if is_preview:
962
+ if command is None:
963
+ sys.exit(1)
964
+ logger.info(f"Preview command '{command.key}' selected.")
965
+ await command.preview()
966
+ sys.exit(0)
946
967
  if not command:
947
- self.console.print(
948
- f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found.[/]"
949
- )
950
968
  sys.exit(1)
951
969
  self._set_retry_policy(command)
952
970
  try:
@@ -954,6 +972,9 @@ class Falyx:
954
972
  except FalyxError as error:
955
973
  self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]")
956
974
  sys.exit(1)
975
+
976
+ if self.cli_args.summary:
977
+ er.summary()
957
978
  sys.exit(0)
958
979
 
959
980
  if self.cli_args.command == "run-all":
@@ -975,6 +996,10 @@ class Falyx:
975
996
  for cmd in matching:
976
997
  self._set_retry_policy(cmd)
977
998
  await self.run_key(cmd.key)
999
+
1000
+ if self.cli_args.summary:
1001
+ er.summary()
1002
+
978
1003
  sys.exit(0)
979
1004
 
980
1005
  await self.menu()
@@ -20,7 +20,6 @@ import subprocess
20
20
  import sys
21
21
  from typing import Any
22
22
 
23
- from rich.console import Console
24
23
  from rich.tree import Tree
25
24
 
26
25
  from falyx.action import BaseAction
@@ -31,8 +30,6 @@ from falyx.hook_manager import HookManager, HookType
31
30
  from falyx.themes.colors import OneColors
32
31
  from falyx.utils import logger
33
32
 
34
- console = Console()
35
-
36
33
 
37
34
  class BaseIOAction(BaseAction):
38
35
  """
@@ -62,6 +59,7 @@ class BaseIOAction(BaseAction):
62
59
  def __init__(
63
60
  self,
64
61
  name: str,
62
+ *,
65
63
  hooks: HookManager | None = None,
66
64
  mode: str = "buffered",
67
65
  logging_hooks: bool = True,
@@ -172,22 +170,7 @@ class BaseIOAction(BaseAction):
172
170
  if parent:
173
171
  parent.add("".join(label))
174
172
  else:
175
- console.print(Tree("".join(label)))
176
-
177
-
178
- class UppercaseIO(BaseIOAction):
179
- def from_input(self, raw: str | bytes) -> str:
180
- if not isinstance(raw, (str, bytes)):
181
- raise TypeError(
182
- f"{self.name} expected str or bytes input, got {type(raw).__name__}"
183
- )
184
- return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip()
185
-
186
- async def _run(self, parsed_input: str, *args, **kwargs) -> str:
187
- return parsed_input.upper()
188
-
189
- def to_output(self, data: str) -> str:
190
- return data + "\n"
173
+ self.console.print(Tree("".join(label)))
191
174
 
192
175
 
193
176
  class ShellAction(BaseIOAction):
@@ -247,41 +230,9 @@ class ShellAction(BaseIOAction):
247
230
  if parent:
248
231
  parent.add("".join(label))
249
232
  else:
250
- console.print(Tree("".join(label)))
233
+ self.console.print(Tree("".join(label)))
251
234
 
252
235
  def __str__(self):
253
236
  return (
254
237
  f"ShellAction(name={self.name!r}, command_template={self.command_template!r})"
255
238
  )
256
-
257
-
258
- class GrepAction(BaseIOAction):
259
- def __init__(self, name: str, pattern: str, **kwargs):
260
- super().__init__(name=name, **kwargs)
261
- self.pattern = pattern
262
-
263
- def from_input(self, raw: str | bytes) -> str:
264
- if not isinstance(raw, (str, bytes)):
265
- raise TypeError(
266
- f"{self.name} expected str or bytes input, got {type(raw).__name__}"
267
- )
268
- return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip()
269
-
270
- async def _run(self, parsed_input: str) -> str:
271
- command = ["grep", "-n", self.pattern]
272
- process = subprocess.Popen(
273
- command,
274
- stdin=subprocess.PIPE,
275
- stdout=subprocess.PIPE,
276
- stderr=subprocess.PIPE,
277
- text=True,
278
- )
279
- stdout, stderr = process.communicate(input=parsed_input)
280
- if process.returncode == 1:
281
- return ""
282
- if process.returncode != 0:
283
- raise RuntimeError(stderr.strip())
284
- return stdout.strip()
285
-
286
- def to_output(self, result: str) -> str:
287
- return result
@@ -45,7 +45,9 @@ class MenuOptionMap(CaseInsensitiveDict):
45
45
  RESERVED_KEYS = {"Q", "B"}
46
46
 
47
47
  def __init__(
48
- self, options: dict[str, MenuOption] | None = None, allow_reserved: bool = False
48
+ self,
49
+ options: dict[str, MenuOption] | None = None,
50
+ allow_reserved: bool = False,
49
51
  ):
50
52
  super().__init__()
51
53
  self.allow_reserved = allow_reserved
@@ -36,7 +36,9 @@ def get_arg_parsers(
36
36
  prog: str | None = "falyx",
37
37
  usage: str | None = None,
38
38
  description: str | None = "Falyx CLI - Run structured async command workflows.",
39
- epilog: str | None = None,
39
+ epilog: (
40
+ str | None
41
+ ) = "Tip: Use 'falyx run ?[COMMAND]' to preview any command from the CLI.",
40
42
  parents: Sequence[ArgumentParser] = [],
41
43
  prefix_chars: str = "-",
42
44
  fromfile_prefix_chars: str | None = None,
@@ -79,6 +81,11 @@ def get_arg_parsers(
79
81
 
80
82
  run_parser = subparsers.add_parser("run", help="Run a specific command")
81
83
  run_parser.add_argument("name", help="Key, alias, or description of the command")
84
+ run_parser.add_argument(
85
+ "--summary",
86
+ action="store_true",
87
+ help="Print an execution summary after command completes",
88
+ )
82
89
  run_parser.add_argument(
83
90
  "--retries", type=int, help="Number of retries on failure", default=0
84
91
  )
@@ -111,6 +118,11 @@ def get_arg_parsers(
111
118
  "run-all", help="Run all commands with a given tag"
112
119
  )
113
120
  run_all_parser.add_argument("-t", "--tag", required=True, help="Tag to match")
121
+ run_all_parser.add_argument(
122
+ "--summary",
123
+ action="store_true",
124
+ help="Print a summary after all tagged commands run",
125
+ )
114
126
  run_all_parser.add_argument(
115
127
  "--retries", type=int, help="Number of retries on failure", default=0
116
128
  )
@@ -0,0 +1,195 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import json
5
+ import xml.etree.ElementTree as ET
6
+ from enum import Enum
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import toml
11
+ import yaml
12
+ from prompt_toolkit import PromptSession
13
+ from rich.console import Console
14
+ from rich.tree import Tree
15
+
16
+ from falyx.action import BaseAction
17
+ from falyx.context import ExecutionContext
18
+ from falyx.execution_registry import ExecutionRegistry as er
19
+ from falyx.hook_manager import HookType
20
+ from falyx.selection import (
21
+ SelectionOption,
22
+ prompt_for_selection,
23
+ render_selection_dict_table,
24
+ )
25
+ from falyx.themes.colors import OneColors
26
+ from falyx.utils import logger
27
+
28
+
29
+ class FileReturnType(Enum):
30
+ TEXT = "text"
31
+ PATH = "path"
32
+ JSON = "json"
33
+ TOML = "toml"
34
+ YAML = "yaml"
35
+ CSV = "csv"
36
+ XML = "xml"
37
+
38
+
39
+ class SelectFileAction(BaseAction):
40
+ """
41
+ SelectFileAction allows users to select a file from a directory and return:
42
+ - file content (as text, JSON, CSV, etc.)
43
+ - or the file path itself.
44
+
45
+ Supported formats: text, json, yaml, toml, csv, xml.
46
+
47
+ Useful for:
48
+ - dynamically loading config files
49
+ - interacting with user-selected data
50
+ - chaining file contents into workflows
51
+
52
+ Args:
53
+ name (str): Name of the action.
54
+ directory (Path | str): Where to search for files.
55
+ title (str): Title of the selection menu.
56
+ columns (int): Number of columns in the selection menu.
57
+ prompt_message (str): Message to display when prompting for selection.
58
+ style (str): Style for the selection options.
59
+ suffix_filter (str | None): Restrict to certain file types.
60
+ return_type (FileReturnType): What to return (path, content, parsed).
61
+ console (Console | None): Console instance for output.
62
+ prompt_session (PromptSession | None): Prompt session for user input.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ name: str,
68
+ directory: Path | str = ".",
69
+ *,
70
+ title: str = "Select a file",
71
+ columns: int = 3,
72
+ prompt_message: str = "Choose > ",
73
+ style: str = OneColors.WHITE,
74
+ suffix_filter: str | None = None,
75
+ return_type: FileReturnType = FileReturnType.PATH,
76
+ console: Console | None = None,
77
+ prompt_session: PromptSession | None = None,
78
+ ):
79
+ super().__init__(name)
80
+ self.directory = Path(directory).resolve()
81
+ self.title = title
82
+ self.columns = columns
83
+ self.prompt_message = prompt_message
84
+ self.suffix_filter = suffix_filter
85
+ self.style = style
86
+ self.return_type = return_type
87
+ self.console = console or Console(color_system="auto")
88
+ self.prompt_session = prompt_session or PromptSession()
89
+
90
+ def get_options(self, files: list[Path]) -> dict[str, SelectionOption]:
91
+ value: Any
92
+ options = {}
93
+ for index, file in enumerate(files):
94
+ try:
95
+ if self.return_type == FileReturnType.TEXT:
96
+ value = file.read_text(encoding="UTF-8")
97
+ elif self.return_type == FileReturnType.PATH:
98
+ value = file
99
+ elif self.return_type == FileReturnType.JSON:
100
+ value = json.loads(file.read_text(encoding="UTF-8"))
101
+ elif self.return_type == FileReturnType.TOML:
102
+ value = toml.loads(file.read_text(encoding="UTF-8"))
103
+ elif self.return_type == FileReturnType.YAML:
104
+ value = yaml.safe_load(file.read_text(encoding="UTF-8"))
105
+ elif self.return_type == FileReturnType.CSV:
106
+ with open(file, newline="", encoding="UTF-8") as csvfile:
107
+ reader = csv.reader(csvfile)
108
+ value = list(reader)
109
+ elif self.return_type == FileReturnType.XML:
110
+ tree = ET.parse(file, parser=ET.XMLParser(encoding="UTF-8"))
111
+ root = tree.getroot()
112
+ value = ET.tostring(root, encoding="unicode")
113
+ else:
114
+ raise ValueError(f"Unsupported return type: {self.return_type}")
115
+
116
+ options[str(index)] = SelectionOption(
117
+ description=file.name, value=value, style=self.style
118
+ )
119
+ except Exception as error:
120
+ logger.warning("[ERROR] Failed to parse %s: %s", file.name, error)
121
+ return options
122
+
123
+ async def _run(self, *args, **kwargs) -> Any:
124
+ context = ExecutionContext(name=self.name, args=args, kwargs=kwargs, action=self)
125
+ context.start_timer()
126
+ try:
127
+ await self.hooks.trigger(HookType.BEFORE, context)
128
+
129
+ files = [
130
+ f
131
+ for f in self.directory.iterdir()
132
+ if f.is_file()
133
+ and (self.suffix_filter is None or f.suffix == self.suffix_filter)
134
+ ]
135
+ if not files:
136
+ raise FileNotFoundError("No files found in directory.")
137
+
138
+ options = self.get_options(files)
139
+
140
+ table = render_selection_dict_table(
141
+ title=self.title, selections=options, columns=self.columns
142
+ )
143
+
144
+ key = await prompt_for_selection(
145
+ options.keys(),
146
+ table,
147
+ console=self.console,
148
+ prompt_session=self.prompt_session,
149
+ prompt_message=self.prompt_message,
150
+ )
151
+
152
+ result = options[key].value
153
+ context.result = result
154
+ await self.hooks.trigger(HookType.ON_SUCCESS, context)
155
+ return result
156
+ except Exception as error:
157
+ context.exception = error
158
+ await self.hooks.trigger(HookType.ON_ERROR, context)
159
+ raise
160
+ finally:
161
+ context.stop_timer()
162
+ await self.hooks.trigger(HookType.AFTER, context)
163
+ await self.hooks.trigger(HookType.ON_TEARDOWN, context)
164
+ er.record(context)
165
+
166
+ async def preview(self, parent: Tree | None = None):
167
+ label = f"[{OneColors.GREEN}]📁 SelectFilesAction[/] '{self.name}'"
168
+ tree = parent.add(label) if parent else Tree(label)
169
+
170
+ tree.add(f"[dim]Directory:[/] {str(self.directory)}")
171
+ tree.add(f"[dim]Suffix filter:[/] {self.suffix_filter or 'None'}")
172
+ tree.add(f"[dim]Return type:[/] {self.return_type}")
173
+ tree.add(f"[dim]Prompt:[/] {self.prompt_message}")
174
+ tree.add(f"[dim]Columns:[/] {self.columns}")
175
+ try:
176
+ files = list(self.directory.iterdir())
177
+ if self.suffix_filter:
178
+ files = [f for f in files if f.suffix == self.suffix_filter]
179
+ sample = files[:10]
180
+ file_list = tree.add("[dim]Files:[/]")
181
+ for f in sample:
182
+ file_list.add(f"[dim]{f.name}[/]")
183
+ if len(files) > 10:
184
+ file_list.add(f"[dim]... ({len(files) - 10} more)[/]")
185
+ except Exception as error:
186
+ tree.add(f"[bold red]⚠️ Error scanning directory: {error}[/]")
187
+
188
+ if not parent:
189
+ self.console.print(tree)
190
+
191
+ def __str__(self) -> str:
192
+ return (
193
+ f"SelectFilesAction(name={self.name!r}, dir={str(self.directory)!r}, "
194
+ f"suffix_filter={self.suffix_filter!r}, return_type={self.return_type})"
195
+ )
@@ -31,6 +31,7 @@ class SelectionOption:
31
31
 
32
32
  def render_table_base(
33
33
  title: str,
34
+ *,
34
35
  caption: str = "",
35
36
  columns: int = 4,
36
37
  box_style: box.Box = box.SIMPLE,
@@ -71,6 +72,7 @@ def render_table_base(
71
72
  def render_selection_grid(
72
73
  title: str,
73
74
  selections: Sequence[str],
75
+ *,
74
76
  columns: int = 4,
75
77
  caption: str = "",
76
78
  box_style: box.Box = box.SIMPLE,
@@ -86,19 +88,19 @@ def render_selection_grid(
86
88
  ) -> Table:
87
89
  """Create a selection table with the given parameters."""
88
90
  table = render_table_base(
89
- title,
90
- caption,
91
- columns,
92
- box_style,
93
- show_lines,
94
- show_header,
95
- show_footer,
96
- style,
97
- header_style,
98
- footer_style,
99
- title_style,
100
- caption_style,
101
- highlight,
91
+ title=title,
92
+ caption=caption,
93
+ columns=columns,
94
+ box_style=box_style,
95
+ show_lines=show_lines,
96
+ show_header=show_header,
97
+ show_footer=show_footer,
98
+ style=style,
99
+ header_style=header_style,
100
+ footer_style=footer_style,
101
+ title_style=title_style,
102
+ caption_style=caption_style,
103
+ highlight=highlight,
102
104
  )
103
105
 
104
106
  for chunk in chunks(selections, columns):
@@ -110,6 +112,7 @@ def render_selection_grid(
110
112
  def render_selection_indexed_table(
111
113
  title: str,
112
114
  selections: Sequence[str],
115
+ *,
113
116
  columns: int = 4,
114
117
  caption: str = "",
115
118
  box_style: box.Box = box.SIMPLE,
@@ -126,19 +129,19 @@ def render_selection_indexed_table(
126
129
  ) -> Table:
127
130
  """Create a selection table with the given parameters."""
128
131
  table = render_table_base(
129
- title,
130
- caption,
131
- columns,
132
- box_style,
133
- show_lines,
134
- show_header,
135
- show_footer,
136
- style,
137
- header_style,
138
- footer_style,
139
- title_style,
140
- caption_style,
141
- highlight,
132
+ title=title,
133
+ caption=caption,
134
+ columns=columns,
135
+ box_style=box_style,
136
+ show_lines=show_lines,
137
+ show_header=show_header,
138
+ show_footer=show_footer,
139
+ style=style,
140
+ header_style=header_style,
141
+ footer_style=footer_style,
142
+ title_style=title_style,
143
+ caption_style=caption_style,
144
+ highlight=highlight,
142
145
  )
143
146
 
144
147
  for indexes, chunk in zip(
@@ -156,6 +159,7 @@ def render_selection_indexed_table(
156
159
  def render_selection_dict_table(
157
160
  title: str,
158
161
  selections: dict[str, SelectionOption],
162
+ *,
159
163
  columns: int = 2,
160
164
  caption: str = "",
161
165
  box_style: box.Box = box.SIMPLE,
@@ -171,19 +175,19 @@ def render_selection_dict_table(
171
175
  ) -> Table:
172
176
  """Create a selection table with the given parameters."""
173
177
  table = render_table_base(
174
- title,
175
- caption,
176
- columns,
177
- box_style,
178
- show_lines,
179
- show_header,
180
- show_footer,
181
- style,
182
- header_style,
183
- footer_style,
184
- title_style,
185
- caption_style,
186
- highlight,
178
+ title=title,
179
+ caption=caption,
180
+ columns=columns,
181
+ box_style=box_style,
182
+ show_lines=show_lines,
183
+ show_header=show_header,
184
+ show_footer=show_footer,
185
+ style=style,
186
+ header_style=header_style,
187
+ footer_style=footer_style,
188
+ title_style=title_style,
189
+ caption_style=caption_style,
190
+ highlight=highlight,
187
191
  )
188
192
 
189
193
  for chunk in chunks(selections.items(), columns):
@@ -200,6 +204,7 @@ def render_selection_dict_table(
200
204
  async def prompt_for_index(
201
205
  max_index: int,
202
206
  table: Table,
207
+ *,
203
208
  min_index: int = 0,
204
209
  default_selection: str = "",
205
210
  console: Console | None = None,
@@ -224,6 +229,7 @@ async def prompt_for_index(
224
229
  async def prompt_for_selection(
225
230
  keys: Sequence[str] | KeysView[str],
226
231
  table: Table,
232
+ *,
227
233
  default_selection: str = "",
228
234
  console: Console | None = None,
229
235
  prompt_session: PromptSession | None = None,
@@ -249,6 +255,7 @@ async def prompt_for_selection(
249
255
  async def select_value_from_list(
250
256
  title: str,
251
257
  selections: Sequence[str],
258
+ *,
252
259
  console: Console | None = None,
253
260
  prompt_session: PromptSession | None = None,
254
261
  prompt_message: str = "Select an option > ",
@@ -268,20 +275,20 @@ async def select_value_from_list(
268
275
  ):
269
276
  """Prompt for a selection. Return the selected item."""
270
277
  table = render_selection_indexed_table(
271
- title,
272
- selections,
273
- columns,
274
- caption,
275
- box_style,
276
- show_lines,
277
- show_header,
278
- show_footer,
279
- style,
280
- header_style,
281
- footer_style,
282
- title_style,
283
- caption_style,
284
- highlight,
278
+ title=title,
279
+ selections=selections,
280
+ columns=columns,
281
+ caption=caption,
282
+ box_style=box_style,
283
+ show_lines=show_lines,
284
+ show_header=show_header,
285
+ show_footer=show_footer,
286
+ style=style,
287
+ header_style=header_style,
288
+ footer_style=footer_style,
289
+ title_style=title_style,
290
+ caption_style=caption_style,
291
+ highlight=highlight,
285
292
  )
286
293
  prompt_session = prompt_session or PromptSession()
287
294
  console = console or Console(color_system="auto")
@@ -301,6 +308,7 @@ async def select_value_from_list(
301
308
  async def select_key_from_dict(
302
309
  selections: dict[str, SelectionOption],
303
310
  table: Table,
311
+ *,
304
312
  console: Console | None = None,
305
313
  prompt_session: PromptSession | None = None,
306
314
  prompt_message: str = "Select an option > ",
@@ -325,6 +333,7 @@ async def select_key_from_dict(
325
333
  async def select_value_from_dict(
326
334
  selections: dict[str, SelectionOption],
327
335
  table: Table,
336
+ *,
328
337
  console: Console | None = None,
329
338
  prompt_session: PromptSession | None = None,
330
339
  prompt_message: str = "Select an option > ",
@@ -351,6 +360,7 @@ async def select_value_from_dict(
351
360
  async def get_selection_from_dict_menu(
352
361
  title: str,
353
362
  selections: dict[str, SelectionOption],
363
+ *,
354
364
  console: Console | None = None,
355
365
  prompt_session: PromptSession | None = None,
356
366
  prompt_message: str = "Select an option > ",
@@ -363,10 +373,10 @@ async def get_selection_from_dict_menu(
363
373
  )
364
374
 
365
375
  return await select_value_from_dict(
366
- selections,
367
- table,
368
- console,
369
- prompt_session,
370
- prompt_message,
371
- default_selection,
376
+ selections=selections,
377
+ table=table,
378
+ console=console,
379
+ prompt_session=prompt_session,
380
+ prompt_message=prompt_message,
381
+ default_selection=default_selection,
372
382
  )
@@ -1,6 +1,5 @@
1
1
  # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
2
  """selection_action.py"""
3
- from pathlib import Path
4
3
  from typing import Any
5
4
 
6
5
  from prompt_toolkit import PromptSession
@@ -117,7 +116,9 @@ class SelectionAction(BaseAction):
117
116
  await self.hooks.trigger(HookType.BEFORE, context)
118
117
  if isinstance(self.selections, list):
119
118
  table = render_selection_indexed_table(
120
- self.title, self.selections, self.columns
119
+ title=self.title,
120
+ selections=self.selections,
121
+ columns=self.columns,
121
122
  )
122
123
  if not self.never_prompt:
123
124
  index = await prompt_for_index(
@@ -134,7 +135,7 @@ class SelectionAction(BaseAction):
134
135
  result = self.selections[int(index)]
135
136
  elif isinstance(self.selections, dict):
136
137
  table = render_selection_dict_table(
137
- self.title, self.selections, self.columns
138
+ title=self.title, selections=self.selections, columns=self.columns
138
139
  )
139
140
  if not self.never_prompt:
140
141
  key = await prompt_for_selection(
@@ -0,0 +1 @@
1
+ __version__ = "0.1.22"
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "falyx"
3
- version = "0.1.20"
3
+ version = "0.1.22"
4
4
  description = "Reliable and introspectable async CLI action framework."
5
5
  authors = ["Roland Thomas Jr <roland@rtj.dev>"]
6
6
  license = "MIT"
@@ -1,68 +0,0 @@
1
-
2
- class SelectFilesAction(BaseAction):
3
- def __init__(
4
- self,
5
- name: str,
6
- directory: Path | str = ".",
7
- title: str = "Select a file",
8
- prompt_message: str = "Choose > ",
9
- style: str = OneColors.WHITE,
10
- suffix_filter: str | None = None,
11
- return_path: bool = True,
12
- console: Console | None = None,
13
- prompt_session: PromptSession | None = None,
14
- ):
15
- super().__init__(name)
16
- self.directory = Path(directory).resolve()
17
- self.title = title
18
- self.prompt_message = prompt_message
19
- self.suffix_filter = suffix_filter
20
- self.style = style
21
- self.return_path = return_path
22
- self.console = console or Console()
23
- self.prompt_session = prompt_session or PromptSession()
24
-
25
- async def _run(self, *args, **kwargs) -> Any:
26
- context = ExecutionContext(name=self.name, args=args, kwargs=kwargs, action=self)
27
- context.start_timer()
28
- try:
29
- await self.hooks.trigger(HookType.BEFORE, context)
30
-
31
- files = [
32
- f
33
- for f in self.directory.iterdir()
34
- if f.is_file()
35
- and (self.suffix_filter is None or f.suffix == self.suffix_filter)
36
- ]
37
- if not files:
38
- raise FileNotFoundError("No files found in directory.")
39
-
40
- options = {
41
- str(i): SelectionOption(
42
- f.name, f if self.return_path else f.read_text(), self.style
43
- )
44
- for i, f in enumerate(files)
45
- }
46
- table = render_selection_dict_table(self.title, options)
47
-
48
- key = await prompt_for_selection(
49
- options.keys(),
50
- table,
51
- console=self.console,
52
- prompt_session=self.prompt_session,
53
- prompt_message=self.prompt_message,
54
- )
55
-
56
- result = options[key].value
57
- context.result = result
58
- await self.hooks.trigger(HookType.ON_SUCCESS, context)
59
- return result
60
- except Exception as error:
61
- context.exception = error
62
- await self.hooks.trigger(HookType.ON_ERROR, context)
63
- raise
64
- finally:
65
- context.stop_timer()
66
- await self.hooks.trigger(HookType.AFTER, context)
67
- await self.hooks.trigger(HookType.ON_TEARDOWN, context)
68
- er.record(context)
@@ -1 +0,0 @@
1
- __version__ = "0.1.20"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes