falyx 0.1.19__tar.gz → 0.1.21__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 (42) hide show
  1. {falyx-0.1.19 → falyx-0.1.21}/PKG-INFO +1 -1
  2. {falyx-0.1.19 → falyx-0.1.21}/falyx/action.py +51 -28
  3. falyx-0.1.21/falyx/action_factory.py +95 -0
  4. {falyx-0.1.19 → falyx-0.1.21}/falyx/bottom_bar.py +1 -1
  5. {falyx-0.1.19 → falyx-0.1.21}/falyx/command.py +1 -1
  6. {falyx-0.1.19 → falyx-0.1.21}/falyx/execution_registry.py +1 -1
  7. {falyx-0.1.19 → falyx-0.1.21}/falyx/falyx.py +44 -12
  8. {falyx-0.1.19 → falyx-0.1.21}/falyx/http_action.py +4 -4
  9. {falyx-0.1.19 → falyx-0.1.21}/falyx/io_action.py +5 -55
  10. {falyx-0.1.19 → falyx-0.1.21}/falyx/menu_action.py +5 -3
  11. falyx-0.1.21/falyx/protocols.py +9 -0
  12. falyx-0.1.21/falyx/select_file_action.py +193 -0
  13. {falyx-0.1.19 → falyx-0.1.21}/falyx/selection_action.py +2 -2
  14. falyx-0.1.21/falyx/version.py +1 -0
  15. {falyx-0.1.19 → falyx-0.1.21}/pyproject.toml +1 -1
  16. falyx-0.1.19/falyx/action_factory.py +0 -23
  17. falyx-0.1.19/falyx/select_files_action.py +0 -68
  18. falyx-0.1.19/falyx/version.py +0 -1
  19. {falyx-0.1.19 → falyx-0.1.21}/LICENSE +0 -0
  20. {falyx-0.1.19 → falyx-0.1.21}/README.md +0 -0
  21. {falyx-0.1.19 → falyx-0.1.21}/falyx/.pytyped +0 -0
  22. {falyx-0.1.19 → falyx-0.1.21}/falyx/__init__.py +0 -0
  23. {falyx-0.1.19 → falyx-0.1.21}/falyx/__main__.py +0 -0
  24. {falyx-0.1.19 → falyx-0.1.21}/falyx/config.py +0 -0
  25. {falyx-0.1.19 → falyx-0.1.21}/falyx/context.py +0 -0
  26. {falyx-0.1.19 → falyx-0.1.21}/falyx/debug.py +0 -0
  27. {falyx-0.1.19 → falyx-0.1.21}/falyx/exceptions.py +0 -0
  28. {falyx-0.1.19 → falyx-0.1.21}/falyx/hook_manager.py +0 -0
  29. {falyx-0.1.19 → falyx-0.1.21}/falyx/hooks.py +0 -0
  30. {falyx-0.1.19 → falyx-0.1.21}/falyx/init.py +0 -0
  31. {falyx-0.1.19 → falyx-0.1.21}/falyx/options_manager.py +0 -0
  32. {falyx-0.1.19 → falyx-0.1.21}/falyx/parsers.py +0 -0
  33. {falyx-0.1.19 → falyx-0.1.21}/falyx/prompt_utils.py +0 -0
  34. {falyx-0.1.19 → falyx-0.1.21}/falyx/retry.py +0 -0
  35. {falyx-0.1.19 → falyx-0.1.21}/falyx/retry_utils.py +0 -0
  36. {falyx-0.1.19 → falyx-0.1.21}/falyx/selection.py +0 -0
  37. {falyx-0.1.19 → falyx-0.1.21}/falyx/signal_action.py +0 -0
  38. {falyx-0.1.19 → falyx-0.1.21}/falyx/signals.py +0 -0
  39. {falyx-0.1.19 → falyx-0.1.21}/falyx/tagged_table.py +0 -0
  40. {falyx-0.1.19 → falyx-0.1.21}/falyx/themes/colors.py +0 -0
  41. {falyx-0.1.19 → falyx-0.1.21}/falyx/utils.py +0 -0
  42. {falyx-0.1.19 → falyx-0.1.21}/falyx/validators.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: falyx
3
- Version: 0.1.19
3
+ Version: 0.1.21
4
4
  Summary: Reliable and introspectable async CLI action framework.
5
5
  License: MIT
6
6
  Author: Roland Thomas Jr
@@ -56,7 +56,7 @@ class BaseAction(ABC):
56
56
  be run independently or as part of Falyx.
57
57
 
58
58
  inject_last_result (bool): Whether to inject the previous action's result into kwargs.
59
- inject_last_result_as (str): The name of the kwarg key to inject the result as
59
+ inject_into (str): The name of the kwarg key to inject the result as
60
60
  (default: 'last_result').
61
61
  _requires_injection (bool): Whether the action requires input injection.
62
62
  """
@@ -64,9 +64,10 @@ 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
- inject_last_result_as: str = "last_result",
70
+ inject_into: str = "last_result",
70
71
  never_prompt: bool = False,
71
72
  logging_hooks: bool = False,
72
73
  ) -> None:
@@ -75,7 +76,7 @@ class BaseAction(ABC):
75
76
  self.is_retryable: bool = False
76
77
  self.shared_context: SharedContext | None = None
77
78
  self.inject_last_result: bool = inject_last_result
78
- self.inject_last_result_as: str = inject_last_result_as
79
+ self.inject_into: str = inject_into
79
80
  self._never_prompt: bool = never_prompt
80
81
  self._requires_injection: bool = False
81
82
  self._skip_in_chain: bool = False
@@ -133,7 +134,7 @@ class BaseAction(ABC):
133
134
 
134
135
  def _maybe_inject_last_result(self, kwargs: dict[str, Any]) -> dict[str, Any]:
135
136
  if self.inject_last_result and self.shared_context:
136
- key = self.inject_last_result_as
137
+ key = self.inject_into
137
138
  if key in kwargs:
138
139
  logger.warning("[%s] ⚠️ Overriding '%s' with last_result", self.name, key)
139
140
  kwargs = dict(kwargs)
@@ -173,7 +174,7 @@ class Action(BaseAction):
173
174
  kwargs (dict, optional): Static keyword arguments.
174
175
  hooks (HookManager, optional): Hook manager for lifecycle events.
175
176
  inject_last_result (bool, optional): Enable last_result injection.
176
- inject_last_result_as (str, optional): Name of injected key.
177
+ inject_into (str, optional): Name of injected key.
177
178
  retry (bool, optional): Enable retry logic.
178
179
  retry_policy (RetryPolicy, optional): Retry settings.
179
180
  """
@@ -182,16 +183,22 @@ 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,
188
190
  hooks: HookManager | None = None,
189
191
  inject_last_result: bool = False,
190
- inject_last_result_as: str = "last_result",
192
+ inject_into: str = "last_result",
191
193
  retry: bool = False,
192
194
  retry_policy: RetryPolicy | None = None,
193
195
  ) -> None:
194
- super().__init__(name, hooks, inject_last_result, inject_last_result_as)
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
@@ -257,7 +264,7 @@ class Action(BaseAction):
257
264
  if context.result is not None:
258
265
  logger.info("[%s] ✅ Recovered: %s", self.name, self.name)
259
266
  return context.result
260
- raise error
267
+ raise
261
268
  finally:
262
269
  context.stop_timer()
263
270
  await self.hooks.trigger(HookType.AFTER, context)
@@ -267,7 +274,7 @@ class Action(BaseAction):
267
274
  async def preview(self, parent: Tree | None = None):
268
275
  label = [f"[{OneColors.GREEN_b}]⚙ Action[/] '{self.name}'"]
269
276
  if self.inject_last_result:
270
- label.append(f" [dim](injects '{self.inject_last_result_as}')[/dim]")
277
+ label.append(f" [dim](injects '{self.inject_into}')[/dim]")
271
278
  if self.retry_policy.enabled:
272
279
  label.append(
273
280
  f"\n[dim]↻ Retries:[/] {self.retry_policy.max_retries}x, "
@@ -413,7 +420,7 @@ class ChainedAction(BaseAction, ActionListMixin):
413
420
  actions (list): List of actions or literals to execute.
414
421
  hooks (HookManager, optional): Hooks for lifecycle events.
415
422
  inject_last_result (bool, optional): Whether to inject last results into kwargs by default.
416
- inject_last_result_as (str, optional): Key name for injection.
423
+ inject_into (str, optional): Key name for injection.
417
424
  auto_inject (bool, optional): Auto-enable injection for subsequent actions.
418
425
  return_list (bool, optional): Whether to return a list of all results. False returns the last result.
419
426
  """
@@ -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
- inject_last_result_as: str = "last_result",
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_last_result_as)
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
@@ -482,9 +495,7 @@ class ChainedAction(BaseAction, ActionListMixin):
482
495
  last_result = shared_context.last_result()
483
496
  try:
484
497
  if self.requires_io_injection() and last_result is not None:
485
- result = await prepared(
486
- **{prepared.inject_last_result_as: last_result}
487
- )
498
+ result = await prepared(**{prepared.inject_into: last_result})
488
499
  else:
489
500
  result = await prepared(*args, **updated_kwargs)
490
501
  except Exception as error:
@@ -559,7 +570,7 @@ class ChainedAction(BaseAction, ActionListMixin):
559
570
  async def preview(self, parent: Tree | None = None):
560
571
  label = [f"[{OneColors.CYAN_b}]⛓ ChainedAction[/] '{self.name}'"]
561
572
  if self.inject_last_result:
562
- label.append(f" [dim](injects '{self.inject_last_result_as}')[/dim]")
573
+ label.append(f" [dim](injects '{self.inject_into}')[/dim]")
563
574
  tree = parent.add("".join(label)) if parent else Tree("".join(label))
564
575
  for action in self.actions:
565
576
  await action.preview(parent=tree)
@@ -603,18 +614,24 @@ class ActionGroup(BaseAction, ActionListMixin):
603
614
  actions (list): List of actions or literals to execute.
604
615
  hooks (HookManager, optional): Hooks for lifecycle events.
605
616
  inject_last_result (bool, optional): Whether to inject last results into kwargs by default.
606
- inject_last_result_as (str, optional): Key name for injection.
617
+ inject_into (str, optional): Key name for injection.
607
618
  """
608
619
 
609
620
  def __init__(
610
621
  self,
611
622
  name: str,
612
623
  actions: list[BaseAction] | None = None,
624
+ *,
613
625
  hooks: HookManager | None = None,
614
626
  inject_last_result: bool = False,
615
- inject_last_result_as: str = "last_result",
627
+ inject_into: str = "last_result",
616
628
  ):
617
- super().__init__(name, hooks, inject_last_result, inject_last_result_as)
629
+ super().__init__(
630
+ name,
631
+ hooks=hooks,
632
+ inject_last_result=inject_last_result,
633
+ inject_into=inject_into,
634
+ )
618
635
  ActionListMixin.__init__(self)
619
636
  if actions:
620
637
  self.set_actions(actions)
@@ -694,7 +711,7 @@ class ActionGroup(BaseAction, ActionListMixin):
694
711
  async def preview(self, parent: Tree | None = None):
695
712
  label = [f"[{OneColors.MAGENTA_b}]⏩ ActionGroup (parallel)[/] '{self.name}'"]
696
713
  if self.inject_last_result:
697
- label.append(f" [dim](receives '{self.inject_last_result_as}')[/dim]")
714
+ label.append(f" [dim](receives '{self.inject_into}')[/dim]")
698
715
  tree = parent.add("".join(label)) if parent else Tree("".join(label))
699
716
  actions = self.actions.copy()
700
717
  random.shuffle(actions)
@@ -726,22 +743,28 @@ class ProcessAction(BaseAction):
726
743
  hooks (HookManager, optional): Hook manager for lifecycle events.
727
744
  executor (ProcessPoolExecutor, optional): Custom executor if desired.
728
745
  inject_last_result (bool, optional): Inject last result into the function.
729
- inject_last_result_as (str, optional): Name of the injected key.
746
+ inject_into (str, optional): Name of the injected key.
730
747
  """
731
748
 
732
749
  def __init__(
733
750
  self,
734
751
  name: str,
735
- func: Callable[..., Any],
752
+ action: Callable[..., Any],
753
+ *,
736
754
  args: tuple = (),
737
755
  kwargs: dict[str, Any] | None = None,
738
756
  hooks: HookManager | None = None,
739
757
  executor: ProcessPoolExecutor | None = None,
740
758
  inject_last_result: bool = False,
741
- inject_last_result_as: str = "last_result",
759
+ inject_into: str = "last_result",
742
760
  ):
743
- super().__init__(name, hooks, inject_last_result, inject_last_result_as)
744
- 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
745
768
  self.args = args
746
769
  self.kwargs = kwargs or {}
747
770
  self.executor = executor or ProcessPoolExecutor()
@@ -769,7 +792,7 @@ class ProcessAction(BaseAction):
769
792
  try:
770
793
  await self.hooks.trigger(HookType.BEFORE, context)
771
794
  result = await loop.run_in_executor(
772
- self.executor, partial(self.func, *combined_args, **combined_kwargs)
795
+ self.executor, partial(self.action, *combined_args, **combined_kwargs)
773
796
  )
774
797
  context.result = result
775
798
  await self.hooks.trigger(HookType.ON_SUCCESS, context)
@@ -800,7 +823,7 @@ class ProcessAction(BaseAction):
800
823
  f"[{OneColors.DARK_YELLOW_b}]🧠 ProcessAction (new process)[/] '{self.name}'"
801
824
  ]
802
825
  if self.inject_last_result:
803
- label.append(f" [dim](injects '{self.inject_last_result_as}')[/dim]")
826
+ label.append(f" [dim](injects '{self.inject_into}')[/dim]")
804
827
  if parent:
805
828
  parent.add("".join(label))
806
829
  else:
@@ -808,6 +831,6 @@ class ProcessAction(BaseAction):
808
831
 
809
832
  def __str__(self) -> str:
810
833
  return (
811
- 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))}, "
812
835
  f"args={self.args!r}, kwargs={self.kwargs!r})"
813
836
  )
@@ -0,0 +1,95 @@
1
+ from typing import Any
2
+
3
+ from rich.tree import Tree
4
+
5
+ from falyx.action import BaseAction
6
+ from falyx.context import ExecutionContext
7
+ from falyx.execution_registry import ExecutionRegistry as er
8
+ from falyx.hook_manager import HookType
9
+ from falyx.protocols import ActionFactoryProtocol
10
+ from falyx.themes.colors import OneColors
11
+
12
+
13
+ class ActionFactoryAction(BaseAction):
14
+ """
15
+ Dynamically creates and runs another Action at runtime using a factory function.
16
+
17
+ This is useful for generating context-specific behavior (e.g., dynamic HTTPActions)
18
+ where the structure of the next action depends on runtime values.
19
+
20
+ Args:
21
+ name (str): Name of the action.
22
+ factory (Callable): A function that returns a BaseAction given args/kwargs.
23
+ inject_last_result (bool): Whether to inject last_result into the factory.
24
+ inject_into (str): The name of the kwarg to inject last_result as.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ name: str,
30
+ factory: ActionFactoryProtocol,
31
+ *,
32
+ inject_last_result: bool = False,
33
+ inject_into: str = "last_result",
34
+ preview_args: tuple[Any, ...] = (),
35
+ preview_kwargs: dict[str, Any] = {},
36
+ ):
37
+ super().__init__(
38
+ name=name,
39
+ inject_last_result=inject_last_result,
40
+ inject_into=inject_into,
41
+ )
42
+ self.factory = factory
43
+ self.preview_args = preview_args
44
+ self.preview_kwargs = preview_kwargs
45
+
46
+ async def _run(self, *args, **kwargs) -> Any:
47
+ updated_kwargs = self._maybe_inject_last_result(kwargs)
48
+ context = ExecutionContext(
49
+ name=f"{self.name} (factory)",
50
+ args=args,
51
+ kwargs=updated_kwargs,
52
+ action=self,
53
+ )
54
+ context.start_timer()
55
+ try:
56
+ await self.hooks.trigger(HookType.BEFORE, context)
57
+ generated_action = self.factory(*args, **updated_kwargs)
58
+ if not isinstance(generated_action, BaseAction):
59
+ raise TypeError(
60
+ f"[{self.name}] Factory must return a BaseAction, got {type(generated_action).__name__}"
61
+ )
62
+ if self.shared_context:
63
+ generated_action.set_shared_context(self.shared_context)
64
+ if self.options_manager:
65
+ generated_action.set_options_manager(self.options_manager)
66
+ context.result = await generated_action(*args, **kwargs)
67
+ await self.hooks.trigger(HookType.ON_SUCCESS, context)
68
+ return context.result
69
+ except Exception as error:
70
+ context.exception = error
71
+ await self.hooks.trigger(HookType.ON_ERROR, context)
72
+ raise
73
+ finally:
74
+ context.stop_timer()
75
+ await self.hooks.trigger(HookType.AFTER, context)
76
+ await self.hooks.trigger(HookType.ON_TEARDOWN, context)
77
+ er.record(context)
78
+
79
+ async def preview(self, parent: Tree | None = None):
80
+ label = f"[{OneColors.CYAN_b}]🏗️ ActionFactory[/] '{self.name}'"
81
+ tree = parent.add(label) if parent else Tree(label)
82
+
83
+ try:
84
+ generated = self.factory(*self.preview_args, **self.preview_kwargs)
85
+ if isinstance(generated, BaseAction):
86
+ await generated.preview(parent=tree)
87
+ else:
88
+ tree.add(
89
+ f"[{OneColors.DARK_RED}]⚠️ Factory did not return a BaseAction[/]"
90
+ )
91
+ except Exception as error:
92
+ tree.add(f"[{OneColors.DARK_RED}]⚠️ Preview failed: {error}[/]")
93
+
94
+ if not parent:
95
+ self.console.print(tree)
@@ -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):
@@ -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):
@@ -24,6 +24,7 @@ import logging
24
24
  import sys
25
25
  from argparse import Namespace
26
26
  from difflib import get_close_matches
27
+ from enum import Enum
27
28
  from functools import cached_property
28
29
  from typing import Any, Callable
29
30
 
@@ -59,6 +60,13 @@ from falyx.utils import CaseInsensitiveDict, chunks, get_program_invocation, log
59
60
  from falyx.version import __version__
60
61
 
61
62
 
63
+ class FalyxMode(str, Enum):
64
+ MENU = "menu"
65
+ RUN = "run"
66
+ PREVIEW = "preview"
67
+ RUN_ALL = "run-all"
68
+
69
+
62
70
  class Falyx:
63
71
  """
64
72
  Main menu controller for Falyx CLI applications.
@@ -102,12 +110,12 @@ class Falyx:
102
110
  register_all_hooks(): Register hooks across all commands and submenus.
103
111
  debug_hooks(): Log hook registration for debugging.
104
112
  build_default_table(): Construct the standard Rich table layout.
105
-
106
113
  """
107
114
 
108
115
  def __init__(
109
116
  self,
110
117
  title: str | Markdown = "Menu",
118
+ *,
111
119
  prompt: str | AnyFormattedText = "> ",
112
120
  columns: int = 3,
113
121
  bottom_bar: BottomBar | str | Callable[[], Any] | None = None,
@@ -135,7 +143,7 @@ class Falyx:
135
143
  self.help_command: Command | None = (
136
144
  self._get_help_command() if include_help_command else None
137
145
  )
138
- self.console: Console = Console(color_system="truecolor", theme=get_nord_theme())
146
+ self.console: Console = Console(color_system="auto", theme=get_nord_theme())
139
147
  self.welcome_message: str | Markdown | dict[str, Any] = welcome_message
140
148
  self.exit_message: str | Markdown | dict[str, Any] = exit_message
141
149
  self.hooks: HookManager = HookManager()
@@ -149,6 +157,7 @@ class Falyx:
149
157
  self.custom_table: Callable[["Falyx"], Table] | Table | None = custom_table
150
158
  self.validate_options(cli_args, options)
151
159
  self._prompt_session: PromptSession | None = None
160
+ self.mode = FalyxMode.MENU
152
161
 
153
162
  def validate_options(
154
163
  self,
@@ -272,6 +281,11 @@ class Falyx:
272
281
  )
273
282
 
274
283
  self.console.print(table, justify="center")
284
+ if self.mode == FalyxMode.MENU:
285
+ self.console.print(
286
+ f"📦 Tip: Type '[{OneColors.LIGHT_YELLOW}]?[KEY][/]' to preview a command before running it.\n",
287
+ justify="center",
288
+ )
275
289
 
276
290
  def _get_help_command(self) -> Command:
277
291
  """Returns the help command for the menu."""
@@ -329,7 +343,8 @@ class Falyx:
329
343
  error_message = " ".join(message_lines)
330
344
 
331
345
  def validator(text):
332
- return True if self.get_command(text, from_validate=True) else False
346
+ _, choice = self.get_command(text, from_validate=True)
347
+ return True if choice else False
333
348
 
334
349
  return Validator.from_callable(
335
350
  validator,
@@ -534,7 +549,7 @@ class Falyx:
534
549
  )
535
550
 
536
551
  def add_submenu(
537
- self, key: str, description: str, submenu: "Falyx", style: str = OneColors.CYAN
552
+ self, key: str, description: str, submenu: "Falyx", *, style: str = OneColors.CYAN
538
553
  ) -> None:
539
554
  """Adds a submenu to the menu."""
540
555
  if not isinstance(submenu, Falyx):
@@ -553,6 +568,7 @@ class Falyx:
553
568
  key: str,
554
569
  description: str,
555
570
  action: BaseAction | Callable[[], Any],
571
+ *,
556
572
  args: tuple = (),
557
573
  kwargs: dict[str, Any] = {},
558
574
  hidden: bool = False,
@@ -668,17 +684,25 @@ class Falyx:
668
684
  else:
669
685
  return self.build_default_table()
670
686
 
671
- def get_command(self, choice: str, from_validate=False) -> Command | None:
687
+ def parse_preview_command(self, input_str: str) -> tuple[bool, str]:
688
+ if input_str.startswith("?"):
689
+ return True, input_str[1:].strip()
690
+ return False, input_str.strip()
691
+
692
+ def get_command(
693
+ self, choice: str, from_validate=False
694
+ ) -> tuple[bool, Command | None]:
672
695
  """Returns the selected command based on user input. Supports keys, aliases, and abbreviations."""
696
+ is_preview, choice = self.parse_preview_command(choice)
673
697
  choice = choice.upper()
674
698
  name_map = self._name_map
675
699
 
676
700
  if choice in name_map:
677
- return name_map[choice]
701
+ return is_preview, name_map[choice]
678
702
 
679
703
  prefix_matches = [cmd for key, cmd in name_map.items() if key.startswith(choice)]
680
704
  if len(prefix_matches) == 1:
681
- return prefix_matches[0]
705
+ return is_preview, prefix_matches[0]
682
706
 
683
707
  fuzzy_matches = get_close_matches(choice, list(name_map.keys()), n=3, cutoff=0.7)
684
708
  if fuzzy_matches:
@@ -694,7 +718,7 @@ class Falyx:
694
718
  self.console.print(
695
719
  f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'[/]"
696
720
  )
697
- return None
721
+ return is_preview, None
698
722
 
699
723
  def _create_context(self, selected_command: Command) -> ExecutionContext:
700
724
  """Creates a context dictionary for the selected command."""
@@ -718,11 +742,16 @@ class Falyx:
718
742
  async def process_command(self) -> bool:
719
743
  """Processes the action of the selected command."""
720
744
  choice = await self.prompt_session.prompt_async()
721
- selected_command = self.get_command(choice)
745
+ is_preview, selected_command = self.get_command(choice)
722
746
  if not selected_command:
723
747
  logger.info(f"Invalid command '{choice}'.")
724
748
  return True
725
749
 
750
+ if is_preview:
751
+ logger.info(f"Preview command '{selected_command.key}' selected.")
752
+ await selected_command.preview()
753
+ return True
754
+
726
755
  if selected_command.requires_input:
727
756
  program = get_program_invocation()
728
757
  self.console.print(
@@ -759,7 +788,7 @@ class Falyx:
759
788
  async def run_key(self, command_key: str, return_context: bool = False) -> Any:
760
789
  """Run a command by key without displaying the menu (non-interactive mode)."""
761
790
  self.debug_hooks()
762
- selected_command = self.get_command(command_key)
791
+ _, selected_command = self.get_command(command_key)
763
792
  self.last_run_command = selected_command
764
793
 
765
794
  if not selected_command:
@@ -899,7 +928,8 @@ class Falyx:
899
928
  sys.exit(0)
900
929
 
901
930
  if self.cli_args.command == "preview":
902
- command = self.get_command(self.cli_args.name)
931
+ self.mode = FalyxMode.PREVIEW
932
+ _, command = self.get_command(self.cli_args.name)
903
933
  if not command:
904
934
  self.console.print(
905
935
  f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found.[/]"
@@ -912,7 +942,8 @@ class Falyx:
912
942
  sys.exit(0)
913
943
 
914
944
  if self.cli_args.command == "run":
915
- command = self.get_command(self.cli_args.name)
945
+ self.mode = FalyxMode.RUN
946
+ _, command = self.get_command(self.cli_args.name)
916
947
  if not command:
917
948
  self.console.print(
918
949
  f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found.[/]"
@@ -927,6 +958,7 @@ class Falyx:
927
958
  sys.exit(0)
928
959
 
929
960
  if self.cli_args.command == "run-all":
961
+ self.mode = FalyxMode.RUN_ALL
930
962
  matching = [
931
963
  cmd
932
964
  for cmd in self.commands.values()
@@ -56,7 +56,7 @@ class HTTPAction(Action):
56
56
  data (Any, optional): Raw data or form-encoded body.
57
57
  hooks (HookManager, optional): Hook manager for lifecycle events.
58
58
  inject_last_result (bool): Enable last_result injection.
59
- inject_last_result_as (str): Name of injected key.
59
+ inject_into (str): Name of injected key.
60
60
  retry (bool): Enable retry logic.
61
61
  retry_policy (RetryPolicy): Retry settings.
62
62
  """
@@ -74,7 +74,7 @@ class HTTPAction(Action):
74
74
  data: Any = None,
75
75
  hooks=None,
76
76
  inject_last_result: bool = False,
77
- inject_last_result_as: str = "last_result",
77
+ inject_into: str = "last_result",
78
78
  retry: bool = False,
79
79
  retry_policy=None,
80
80
  ):
@@ -92,7 +92,7 @@ class HTTPAction(Action):
92
92
  kwargs={},
93
93
  hooks=hooks,
94
94
  inject_last_result=inject_last_result,
95
- inject_last_result_as=inject_last_result_as,
95
+ inject_into=inject_into,
96
96
  retry=retry,
97
97
  retry_policy=retry_policy,
98
98
  )
@@ -138,7 +138,7 @@ class HTTPAction(Action):
138
138
  f"\n[dim]URL:[/] {self.url}",
139
139
  ]
140
140
  if self.inject_last_result:
141
- label.append(f"\n[dim]Injects:[/] '{self.inject_last_result_as}'")
141
+ label.append(f"\n[dim]Injects:[/] '{self.inject_into}'")
142
142
  if self.retry_policy and self.retry_policy.enabled:
143
143
  label.append(
144
144
  f"\n[dim]↻ Retries:[/] {self.retry_policy.max_retries}x, "
@@ -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
  """
@@ -83,7 +80,7 @@ class BaseIOAction(BaseAction):
83
80
  raise NotImplementedError
84
81
 
85
82
  async def _resolve_input(self, kwargs: dict[str, Any]) -> str | bytes:
86
- last_result = kwargs.pop(self.inject_last_result_as, None)
83
+ last_result = kwargs.pop(self.inject_into, None)
87
84
 
88
85
  data = await self._read_stdin()
89
86
  if data:
@@ -168,26 +165,11 @@ class BaseIOAction(BaseAction):
168
165
  async def preview(self, parent: Tree | None = None):
169
166
  label = [f"[{OneColors.GREEN_b}]⚙ IOAction[/] '{self.name}'"]
170
167
  if self.inject_last_result:
171
- label.append(f" [dim](injects '{self.inject_last_result_as}')[/dim]")
168
+ label.append(f" [dim](injects '{self.inject_into}')[/dim]")
172
169
  if parent:
173
170
  parent.add("".join(label))
174
171
  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"
172
+ self.console.print(Tree("".join(label)))
191
173
 
192
174
 
193
175
  class ShellAction(BaseIOAction):
@@ -243,45 +225,13 @@ class ShellAction(BaseIOAction):
243
225
  async def preview(self, parent: Tree | None = None):
244
226
  label = [f"[{OneColors.GREEN_b}]⚙ ShellAction[/] '{self.name}'"]
245
227
  if self.inject_last_result:
246
- label.append(f" [dim](injects '{self.inject_last_result_as}')[/dim]")
228
+ label.append(f" [dim](injects '{self.inject_into}')[/dim]")
247
229
  if parent:
248
230
  parent.add("".join(label))
249
231
  else:
250
- console.print(Tree("".join(label)))
232
+ self.console.print(Tree("".join(label)))
251
233
 
252
234
  def __str__(self):
253
235
  return (
254
236
  f"ShellAction(name={self.name!r}, command_template={self.command_template!r})"
255
237
  )
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
@@ -101,7 +103,7 @@ class MenuAction(BaseAction):
101
103
  prompt_message: str = "Select > ",
102
104
  default_selection: str = "",
103
105
  inject_last_result: bool = False,
104
- inject_last_result_as: str = "last_result",
106
+ inject_into: str = "last_result",
105
107
  console: Console | None = None,
106
108
  prompt_session: PromptSession | None = None,
107
109
  never_prompt: bool = False,
@@ -111,7 +113,7 @@ class MenuAction(BaseAction):
111
113
  super().__init__(
112
114
  name,
113
115
  inject_last_result=inject_last_result,
114
- inject_last_result_as=inject_last_result_as,
116
+ inject_into=inject_into,
115
117
  never_prompt=never_prompt,
116
118
  )
117
119
  self.menu_options = menu_options
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Protocol
4
+
5
+ from falyx.action import BaseAction
6
+
7
+
8
+ class ActionFactoryProtocol(Protocol):
9
+ def __call__(self, *args: Any, **kwargs: Any) -> BaseAction: ...
@@ -0,0 +1,193 @@
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(self.title, options, self.columns)
141
+
142
+ key = await prompt_for_selection(
143
+ options.keys(),
144
+ table,
145
+ console=self.console,
146
+ prompt_session=self.prompt_session,
147
+ prompt_message=self.prompt_message,
148
+ )
149
+
150
+ result = options[key].value
151
+ context.result = result
152
+ await self.hooks.trigger(HookType.ON_SUCCESS, context)
153
+ return result
154
+ except Exception as error:
155
+ context.exception = error
156
+ await self.hooks.trigger(HookType.ON_ERROR, context)
157
+ raise
158
+ finally:
159
+ context.stop_timer()
160
+ await self.hooks.trigger(HookType.AFTER, context)
161
+ await self.hooks.trigger(HookType.ON_TEARDOWN, context)
162
+ er.record(context)
163
+
164
+ async def preview(self, parent: Tree | None = None):
165
+ label = f"[{OneColors.GREEN}]📁 SelectFilesAction[/] '{self.name}'"
166
+ tree = parent.add(label) if parent else Tree(label)
167
+
168
+ tree.add(f"[dim]Directory:[/] {str(self.directory)}")
169
+ tree.add(f"[dim]Suffix filter:[/] {self.suffix_filter or 'None'}")
170
+ tree.add(f"[dim]Return type:[/] {self.return_type}")
171
+ tree.add(f"[dim]Prompt:[/] {self.prompt_message}")
172
+ tree.add(f"[dim]Columns:[/] {self.columns}")
173
+ try:
174
+ files = list(self.directory.iterdir())
175
+ if self.suffix_filter:
176
+ files = [f for f in files if f.suffix == self.suffix_filter]
177
+ sample = files[:10]
178
+ file_list = tree.add("[dim]Files:[/]")
179
+ for f in sample:
180
+ file_list.add(f"[dim]{f.name}[/]")
181
+ if len(files) > 10:
182
+ file_list.add(f"[dim]... ({len(files) - 10} more)[/]")
183
+ except Exception as error:
184
+ tree.add(f"[bold red]⚠️ Error scanning directory: {error}[/]")
185
+
186
+ if not parent:
187
+ self.console.print(tree)
188
+
189
+ def __str__(self) -> str:
190
+ return (
191
+ f"SelectFilesAction(name={self.name!r}, dir={str(self.directory)!r}, "
192
+ f"suffix_filter={self.suffix_filter!r}, return_type={self.return_type})"
193
+ )
@@ -33,7 +33,7 @@ class SelectionAction(BaseAction):
33
33
  prompt_message: str = "Select > ",
34
34
  default_selection: str = "",
35
35
  inject_last_result: bool = False,
36
- inject_last_result_as: str = "last_result",
36
+ inject_into: str = "last_result",
37
37
  return_key: bool = False,
38
38
  console: Console | None = None,
39
39
  prompt_session: PromptSession | None = None,
@@ -43,7 +43,7 @@ class SelectionAction(BaseAction):
43
43
  super().__init__(
44
44
  name,
45
45
  inject_last_result=inject_last_result,
46
- inject_last_result_as=inject_last_result_as,
46
+ inject_into=inject_into,
47
47
  never_prompt=never_prompt,
48
48
  )
49
49
  self.selections: list[str] | CaseInsensitiveDict = selections
@@ -0,0 +1 @@
1
+ __version__ = "0.1.21"
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "falyx"
3
- version = "0.1.19"
3
+ version = "0.1.21"
4
4
  description = "Reliable and introspectable async CLI action framework."
5
5
  authors = ["Roland Thomas Jr <roland@rtj.dev>"]
6
6
  license = "MIT"
@@ -1,23 +0,0 @@
1
- from typing import Callable
2
-
3
- from falyx.action import BaseAction
4
-
5
-
6
- class ActionFactoryAction(BaseAction):
7
- def __init__(
8
- self,
9
- name: str,
10
- factory: Callable[[dict], BaseAction],
11
- *,
12
- inject_last_result: bool = False,
13
- inject_last_result_as: str = "last_result",
14
- ):
15
- super().__init__(name, inject_last_result=inject_last_result, inject_last_result_as=inject_last_result_as)
16
- self.factory = factory
17
-
18
- async def _run(self, *args, **kwargs) -> BaseAction:
19
- kwargs = self._maybe_inject_last_result(kwargs)
20
- action = self.factory(kwargs)
21
- if not isinstance(action, BaseAction):
22
- raise TypeError(f"[{self.name}] Factory did not return a valid BaseAction.")
23
- return action
@@ -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.19"
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
File without changes