falyx 0.1.18__tar.gz → 0.1.20__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.18 → falyx-0.1.20}/PKG-INFO +1 -1
  2. {falyx-0.1.18 → falyx-0.1.20}/falyx/action.py +40 -24
  3. falyx-0.1.20/falyx/action_factory.py +95 -0
  4. {falyx-0.1.18 → falyx-0.1.20}/falyx/falyx.py +52 -21
  5. {falyx-0.1.18 → falyx-0.1.20}/falyx/http_action.py +8 -5
  6. {falyx-0.1.18 → falyx-0.1.20}/falyx/init.py +4 -2
  7. {falyx-0.1.18 → falyx-0.1.20}/falyx/io_action.py +3 -3
  8. {falyx-0.1.18 → falyx-0.1.20}/falyx/menu_action.py +4 -6
  9. {falyx-0.1.18 → falyx-0.1.20}/falyx/prompt_utils.py +1 -2
  10. falyx-0.1.20/falyx/protocols.py +9 -0
  11. {falyx-0.1.18 → falyx-0.1.20}/falyx/select_files_action.py +3 -3
  12. {falyx-0.1.18 → falyx-0.1.20}/falyx/selection.py +17 -17
  13. {falyx-0.1.18 → falyx-0.1.20}/falyx/selection_action.py +12 -10
  14. falyx-0.1.20/falyx/version.py +1 -0
  15. {falyx-0.1.18 → falyx-0.1.20}/pyproject.toml +1 -1
  16. falyx-0.1.18/falyx/.coverage +0 -0
  17. falyx-0.1.18/falyx/version.py +0 -1
  18. {falyx-0.1.18 → falyx-0.1.20}/LICENSE +0 -0
  19. {falyx-0.1.18 → falyx-0.1.20}/README.md +0 -0
  20. {falyx-0.1.18 → falyx-0.1.20}/falyx/.pytyped +0 -0
  21. {falyx-0.1.18 → falyx-0.1.20}/falyx/__init__.py +0 -0
  22. {falyx-0.1.18 → falyx-0.1.20}/falyx/__main__.py +0 -0
  23. {falyx-0.1.18 → falyx-0.1.20}/falyx/bottom_bar.py +0 -0
  24. {falyx-0.1.18 → falyx-0.1.20}/falyx/command.py +0 -0
  25. {falyx-0.1.18 → falyx-0.1.20}/falyx/config.py +0 -0
  26. {falyx-0.1.18 → falyx-0.1.20}/falyx/context.py +0 -0
  27. {falyx-0.1.18 → falyx-0.1.20}/falyx/debug.py +0 -0
  28. {falyx-0.1.18 → falyx-0.1.20}/falyx/exceptions.py +0 -0
  29. {falyx-0.1.18 → falyx-0.1.20}/falyx/execution_registry.py +0 -0
  30. {falyx-0.1.18 → falyx-0.1.20}/falyx/hook_manager.py +0 -0
  31. {falyx-0.1.18 → falyx-0.1.20}/falyx/hooks.py +0 -0
  32. {falyx-0.1.18 → falyx-0.1.20}/falyx/options_manager.py +0 -0
  33. {falyx-0.1.18 → falyx-0.1.20}/falyx/parsers.py +0 -0
  34. {falyx-0.1.18 → falyx-0.1.20}/falyx/retry.py +0 -0
  35. {falyx-0.1.18 → falyx-0.1.20}/falyx/retry_utils.py +0 -0
  36. {falyx-0.1.18 → falyx-0.1.20}/falyx/signal_action.py +0 -0
  37. {falyx-0.1.18 → falyx-0.1.20}/falyx/signals.py +0 -0
  38. {falyx-0.1.18 → falyx-0.1.20}/falyx/tagged_table.py +0 -0
  39. {falyx-0.1.18 → falyx-0.1.20}/falyx/themes/colors.py +0 -0
  40. {falyx-0.1.18 → falyx-0.1.20}/falyx/utils.py +0 -0
  41. {falyx-0.1.18 → falyx-0.1.20}/falyx/validators.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: falyx
3
- Version: 0.1.18
3
+ Version: 0.1.20
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
  """
@@ -66,7 +66,7 @@ class BaseAction(ABC):
66
66
  name: str,
67
67
  hooks: HookManager | None = None,
68
68
  inject_last_result: bool = False,
69
- inject_last_result_as: str = "last_result",
69
+ inject_into: str = "last_result",
70
70
  never_prompt: bool = False,
71
71
  logging_hooks: bool = False,
72
72
  ) -> None:
@@ -75,7 +75,7 @@ class BaseAction(ABC):
75
75
  self.is_retryable: bool = False
76
76
  self.shared_context: SharedContext | None = None
77
77
  self.inject_last_result: bool = inject_last_result
78
- self.inject_last_result_as: str = inject_last_result_as
78
+ self.inject_into: str = inject_into
79
79
  self._never_prompt: bool = never_prompt
80
80
  self._requires_injection: bool = False
81
81
  self._skip_in_chain: bool = False
@@ -133,7 +133,7 @@ class BaseAction(ABC):
133
133
 
134
134
  def _maybe_inject_last_result(self, kwargs: dict[str, Any]) -> dict[str, Any]:
135
135
  if self.inject_last_result and self.shared_context:
136
- key = self.inject_last_result_as
136
+ key = self.inject_into
137
137
  if key in kwargs:
138
138
  logger.warning("[%s] ⚠️ Overriding '%s' with last_result", self.name, key)
139
139
  kwargs = dict(kwargs)
@@ -173,7 +173,7 @@ class Action(BaseAction):
173
173
  kwargs (dict, optional): Static keyword arguments.
174
174
  hooks (HookManager, optional): Hook manager for lifecycle events.
175
175
  inject_last_result (bool, optional): Enable last_result injection.
176
- inject_last_result_as (str, optional): Name of injected key.
176
+ inject_into (str, optional): Name of injected key.
177
177
  retry (bool, optional): Enable retry logic.
178
178
  retry_policy (RetryPolicy, optional): Retry settings.
179
179
  """
@@ -187,11 +187,11 @@ class Action(BaseAction):
187
187
  kwargs: dict[str, Any] | None = None,
188
188
  hooks: HookManager | None = None,
189
189
  inject_last_result: bool = False,
190
- inject_last_result_as: str = "last_result",
190
+ inject_into: str = "last_result",
191
191
  retry: bool = False,
192
192
  retry_policy: RetryPolicy | None = None,
193
193
  ) -> None:
194
- super().__init__(name, hooks, inject_last_result, inject_last_result_as)
194
+ super().__init__(name, hooks, inject_last_result, inject_into)
195
195
  self.action = action
196
196
  self.rollback = rollback
197
197
  self.args = args
@@ -257,7 +257,7 @@ class Action(BaseAction):
257
257
  if context.result is not None:
258
258
  logger.info("[%s] ✅ Recovered: %s", self.name, self.name)
259
259
  return context.result
260
- raise error
260
+ raise
261
261
  finally:
262
262
  context.stop_timer()
263
263
  await self.hooks.trigger(HookType.AFTER, context)
@@ -267,7 +267,7 @@ class Action(BaseAction):
267
267
  async def preview(self, parent: Tree | None = None):
268
268
  label = [f"[{OneColors.GREEN_b}]⚙ Action[/] '{self.name}'"]
269
269
  if self.inject_last_result:
270
- label.append(f" [dim](injects '{self.inject_last_result_as}')[/dim]")
270
+ label.append(f" [dim](injects '{self.inject_into}')[/dim]")
271
271
  if self.retry_policy.enabled:
272
272
  label.append(
273
273
  f"\n[dim]↻ Retries:[/] {self.retry_policy.max_retries}x, "
@@ -413,7 +413,7 @@ class ChainedAction(BaseAction, ActionListMixin):
413
413
  actions (list): List of actions or literals to execute.
414
414
  hooks (HookManager, optional): Hooks for lifecycle events.
415
415
  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.
416
+ inject_into (str, optional): Key name for injection.
417
417
  auto_inject (bool, optional): Auto-enable injection for subsequent actions.
418
418
  return_list (bool, optional): Whether to return a list of all results. False returns the last result.
419
419
  """
@@ -424,11 +424,11 @@ class ChainedAction(BaseAction, ActionListMixin):
424
424
  actions: list[BaseAction | Any] | None = None,
425
425
  hooks: HookManager | None = None,
426
426
  inject_last_result: bool = False,
427
- inject_last_result_as: str = "last_result",
427
+ inject_into: str = "last_result",
428
428
  auto_inject: bool = False,
429
429
  return_list: bool = False,
430
430
  ) -> None:
431
- super().__init__(name, hooks, inject_last_result, inject_last_result_as)
431
+ super().__init__(name, hooks, inject_last_result, inject_into)
432
432
  ActionListMixin.__init__(self)
433
433
  self.auto_inject = auto_inject
434
434
  self.return_list = return_list
@@ -448,6 +448,8 @@ class ChainedAction(BaseAction, ActionListMixin):
448
448
  if self.actions and self.auto_inject and not action.inject_last_result:
449
449
  action.inject_last_result = True
450
450
  super().add_action(action)
451
+ if hasattr(action, "register_teardown") and callable(action.register_teardown):
452
+ action.register_teardown(self.hooks)
451
453
 
452
454
  async def _run(self, *args, **kwargs) -> list[Any]:
453
455
  if not self.actions:
@@ -480,9 +482,7 @@ class ChainedAction(BaseAction, ActionListMixin):
480
482
  last_result = shared_context.last_result()
481
483
  try:
482
484
  if self.requires_io_injection() and last_result is not None:
483
- result = await prepared(
484
- **{prepared.inject_last_result_as: last_result}
485
- )
485
+ result = await prepared(**{prepared.inject_into: last_result})
486
486
  else:
487
487
  result = await prepared(*args, **updated_kwargs)
488
488
  except Exception as error:
@@ -557,7 +557,7 @@ class ChainedAction(BaseAction, ActionListMixin):
557
557
  async def preview(self, parent: Tree | None = None):
558
558
  label = [f"[{OneColors.CYAN_b}]⛓ ChainedAction[/] '{self.name}'"]
559
559
  if self.inject_last_result:
560
- label.append(f" [dim](injects '{self.inject_last_result_as}')[/dim]")
560
+ label.append(f" [dim](injects '{self.inject_into}')[/dim]")
561
561
  tree = parent.add("".join(label)) if parent else Tree("".join(label))
562
562
  for action in self.actions:
563
563
  await action.preview(parent=tree)
@@ -601,7 +601,7 @@ class ActionGroup(BaseAction, ActionListMixin):
601
601
  actions (list): List of actions or literals to execute.
602
602
  hooks (HookManager, optional): Hooks for lifecycle events.
603
603
  inject_last_result (bool, optional): Whether to inject last results into kwargs by default.
604
- inject_last_result_as (str, optional): Key name for injection.
604
+ inject_into (str, optional): Key name for injection.
605
605
  """
606
606
 
607
607
  def __init__(
@@ -610,13 +610,29 @@ class ActionGroup(BaseAction, ActionListMixin):
610
610
  actions: list[BaseAction] | None = None,
611
611
  hooks: HookManager | None = None,
612
612
  inject_last_result: bool = False,
613
- inject_last_result_as: str = "last_result",
613
+ inject_into: str = "last_result",
614
614
  ):
615
- super().__init__(name, hooks, inject_last_result, inject_last_result_as)
615
+ super().__init__(name, hooks, inject_last_result, inject_into)
616
616
  ActionListMixin.__init__(self)
617
617
  if actions:
618
618
  self.set_actions(actions)
619
619
 
620
+ def _wrap_if_needed(self, action: BaseAction | Any) -> BaseAction:
621
+ if isinstance(action, BaseAction):
622
+ return action
623
+ elif callable(action):
624
+ return Action(name=action.__name__, action=action)
625
+ else:
626
+ raise TypeError(
627
+ f"ActionGroup only accepts BaseAction or callable, got {type(action).__name__}"
628
+ )
629
+
630
+ def add_action(self, action: BaseAction | Any) -> None:
631
+ action = self._wrap_if_needed(action)
632
+ super().add_action(action)
633
+ if hasattr(action, "register_teardown") and callable(action.register_teardown):
634
+ action.register_teardown(self.hooks)
635
+
620
636
  async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]:
621
637
  shared_context = SharedContext(name=self.name, is_parallel=True)
622
638
  if self.shared_context:
@@ -676,7 +692,7 @@ class ActionGroup(BaseAction, ActionListMixin):
676
692
  async def preview(self, parent: Tree | None = None):
677
693
  label = [f"[{OneColors.MAGENTA_b}]⏩ ActionGroup (parallel)[/] '{self.name}'"]
678
694
  if self.inject_last_result:
679
- label.append(f" [dim](receives '{self.inject_last_result_as}')[/dim]")
695
+ label.append(f" [dim](receives '{self.inject_into}')[/dim]")
680
696
  tree = parent.add("".join(label)) if parent else Tree("".join(label))
681
697
  actions = self.actions.copy()
682
698
  random.shuffle(actions)
@@ -708,7 +724,7 @@ class ProcessAction(BaseAction):
708
724
  hooks (HookManager, optional): Hook manager for lifecycle events.
709
725
  executor (ProcessPoolExecutor, optional): Custom executor if desired.
710
726
  inject_last_result (bool, optional): Inject last result into the function.
711
- inject_last_result_as (str, optional): Name of the injected key.
727
+ inject_into (str, optional): Name of the injected key.
712
728
  """
713
729
 
714
730
  def __init__(
@@ -720,9 +736,9 @@ class ProcessAction(BaseAction):
720
736
  hooks: HookManager | None = None,
721
737
  executor: ProcessPoolExecutor | None = None,
722
738
  inject_last_result: bool = False,
723
- inject_last_result_as: str = "last_result",
739
+ inject_into: str = "last_result",
724
740
  ):
725
- super().__init__(name, hooks, inject_last_result, inject_last_result_as)
741
+ super().__init__(name, hooks, inject_last_result, inject_into)
726
742
  self.func = func
727
743
  self.args = args
728
744
  self.kwargs = kwargs or {}
@@ -782,7 +798,7 @@ class ProcessAction(BaseAction):
782
798
  f"[{OneColors.DARK_YELLOW_b}]🧠 ProcessAction (new process)[/] '{self.name}'"
783
799
  ]
784
800
  if self.inject_last_result:
785
- label.append(f" [dim](injects '{self.inject_last_result_as}')[/dim]")
801
+ label.append(f" [dim](injects '{self.inject_into}')[/dim]")
786
802
  if parent:
787
803
  parent.add("".join(label))
788
804
  else:
@@ -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)
@@ -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.
@@ -148,7 +156,8 @@ class Falyx:
148
156
  self.render_menu: Callable[["Falyx"], None] | None = render_menu
149
157
  self.custom_table: Callable[["Falyx"], Table] | Table | None = custom_table
150
158
  self.validate_options(cli_args, options)
151
- self._session: PromptSession | None = None
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,
@@ -337,11 +352,11 @@ class Falyx:
337
352
  move_cursor_to_end=True,
338
353
  )
339
354
 
340
- def _invalidate_session_cache(self):
341
- """Forces the session to be recreated on the next access."""
342
- if hasattr(self, "session"):
343
- del self.session
344
- self._session = None
355
+ def _invalidate_prompt_session_cache(self):
356
+ """Forces the prompt session to be recreated on the next access."""
357
+ if hasattr(self, "prompt_session"):
358
+ del self.prompt_session
359
+ self._prompt_session = None
345
360
 
346
361
  def add_help_command(self):
347
362
  """Adds a help command to the menu if it doesn't already exist."""
@@ -375,7 +390,7 @@ class Falyx:
375
390
  raise FalyxError(
376
391
  "Bottom bar must be a string, callable, or BottomBar instance."
377
392
  )
378
- self._invalidate_session_cache()
393
+ self._invalidate_prompt_session_cache()
379
394
 
380
395
  def _get_bottom_bar_render(self) -> Callable[[], Any] | str | None:
381
396
  """Returns the bottom bar for the menu."""
@@ -390,10 +405,10 @@ class Falyx:
390
405
  return None
391
406
 
392
407
  @cached_property
393
- def session(self) -> PromptSession:
408
+ def prompt_session(self) -> PromptSession:
394
409
  """Returns the prompt session for the menu."""
395
- if self._session is None:
396
- self._session = PromptSession(
410
+ if self._prompt_session is None:
411
+ self._prompt_session = PromptSession(
397
412
  message=self.prompt,
398
413
  multiline=False,
399
414
  completer=self._get_completer(),
@@ -402,7 +417,7 @@ class Falyx:
402
417
  bottom_toolbar=self._get_bottom_bar_render(),
403
418
  key_bindings=self.key_bindings,
404
419
  )
405
- return self._session
420
+ return self._prompt_session
406
421
 
407
422
  def register_all_hooks(self, hook_type: HookType, hooks: Hook | list[Hook]) -> None:
408
423
  """Registers hooks for all commands in the menu and actions recursively."""
@@ -668,17 +683,25 @@ class Falyx:
668
683
  else:
669
684
  return self.build_default_table()
670
685
 
671
- def get_command(self, choice: str, from_validate=False) -> Command | None:
686
+ def parse_preview_command(self, input_str: str) -> tuple[bool, str]:
687
+ if input_str.startswith("?"):
688
+ return True, input_str[1:].strip()
689
+ return False, input_str.strip()
690
+
691
+ def get_command(
692
+ self, choice: str, from_validate=False
693
+ ) -> tuple[bool, Command | None]:
672
694
  """Returns the selected command based on user input. Supports keys, aliases, and abbreviations."""
695
+ is_preview, choice = self.parse_preview_command(choice)
673
696
  choice = choice.upper()
674
697
  name_map = self._name_map
675
698
 
676
699
  if choice in name_map:
677
- return name_map[choice]
700
+ return is_preview, name_map[choice]
678
701
 
679
702
  prefix_matches = [cmd for key, cmd in name_map.items() if key.startswith(choice)]
680
703
  if len(prefix_matches) == 1:
681
- return prefix_matches[0]
704
+ return is_preview, prefix_matches[0]
682
705
 
683
706
  fuzzy_matches = get_close_matches(choice, list(name_map.keys()), n=3, cutoff=0.7)
684
707
  if fuzzy_matches:
@@ -694,7 +717,7 @@ class Falyx:
694
717
  self.console.print(
695
718
  f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'[/]"
696
719
  )
697
- return None
720
+ return is_preview, None
698
721
 
699
722
  def _create_context(self, selected_command: Command) -> ExecutionContext:
700
723
  """Creates a context dictionary for the selected command."""
@@ -717,12 +740,17 @@ class Falyx:
717
740
 
718
741
  async def process_command(self) -> bool:
719
742
  """Processes the action of the selected command."""
720
- choice = await self.session.prompt_async()
721
- selected_command = self.get_command(choice)
743
+ choice = await self.prompt_session.prompt_async()
744
+ is_preview, selected_command = self.get_command(choice)
722
745
  if not selected_command:
723
746
  logger.info(f"Invalid command '{choice}'.")
724
747
  return True
725
748
 
749
+ if is_preview:
750
+ logger.info(f"Preview command '{selected_command.key}' selected.")
751
+ await selected_command.preview()
752
+ return True
753
+
726
754
  if selected_command.requires_input:
727
755
  program = get_program_invocation()
728
756
  self.console.print(
@@ -759,7 +787,7 @@ class Falyx:
759
787
  async def run_key(self, command_key: str, return_context: bool = False) -> Any:
760
788
  """Run a command by key without displaying the menu (non-interactive mode)."""
761
789
  self.debug_hooks()
762
- selected_command = self.get_command(command_key)
790
+ _, selected_command = self.get_command(command_key)
763
791
  self.last_run_command = selected_command
764
792
 
765
793
  if not selected_command:
@@ -899,7 +927,8 @@ class Falyx:
899
927
  sys.exit(0)
900
928
 
901
929
  if self.cli_args.command == "preview":
902
- command = self.get_command(self.cli_args.name)
930
+ self.mode = FalyxMode.PREVIEW
931
+ _, command = self.get_command(self.cli_args.name)
903
932
  if not command:
904
933
  self.console.print(
905
934
  f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found.[/]"
@@ -912,7 +941,8 @@ class Falyx:
912
941
  sys.exit(0)
913
942
 
914
943
  if self.cli_args.command == "run":
915
- command = self.get_command(self.cli_args.name)
944
+ self.mode = FalyxMode.RUN
945
+ _, command = self.get_command(self.cli_args.name)
916
946
  if not command:
917
947
  self.console.print(
918
948
  f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found.[/]"
@@ -927,6 +957,7 @@ class Falyx:
927
957
  sys.exit(0)
928
958
 
929
959
  if self.cli_args.command == "run-all":
960
+ self.mode = FalyxMode.RUN_ALL
930
961
  matching = [
931
962
  cmd
932
963
  for cmd in self.commands.values()
@@ -15,6 +15,7 @@ from rich.tree import Tree
15
15
 
16
16
  from falyx.action import Action
17
17
  from falyx.context import ExecutionContext, SharedContext
18
+ from falyx.hook_manager import HookManager, HookType
18
19
  from falyx.themes.colors import OneColors
19
20
  from falyx.utils import logger
20
21
 
@@ -55,7 +56,7 @@ class HTTPAction(Action):
55
56
  data (Any, optional): Raw data or form-encoded body.
56
57
  hooks (HookManager, optional): Hook manager for lifecycle events.
57
58
  inject_last_result (bool): Enable last_result injection.
58
- inject_last_result_as (str): Name of injected key.
59
+ inject_into (str): Name of injected key.
59
60
  retry (bool): Enable retry logic.
60
61
  retry_policy (RetryPolicy): Retry settings.
61
62
  """
@@ -73,7 +74,7 @@ class HTTPAction(Action):
73
74
  data: Any = None,
74
75
  hooks=None,
75
76
  inject_last_result: bool = False,
76
- inject_last_result_as: str = "last_result",
77
+ inject_into: str = "last_result",
77
78
  retry: bool = False,
78
79
  retry_policy=None,
79
80
  ):
@@ -91,13 +92,12 @@ class HTTPAction(Action):
91
92
  kwargs={},
92
93
  hooks=hooks,
93
94
  inject_last_result=inject_last_result,
94
- inject_last_result_as=inject_last_result_as,
95
+ inject_into=inject_into,
95
96
  retry=retry,
96
97
  retry_policy=retry_policy,
97
98
  )
98
99
 
99
100
  async def _request(self, *args, **kwargs) -> dict[str, Any]:
100
- # TODO: Add check for HOOK registration
101
101
  if self.shared_context:
102
102
  context: SharedContext = self.shared_context
103
103
  session = context.get("http_session")
@@ -128,6 +128,9 @@ class HTTPAction(Action):
128
128
  if not self.shared_context:
129
129
  await session.close()
130
130
 
131
+ def register_teardown(self, hooks: HookManager):
132
+ hooks.register(HookType.ON_TEARDOWN, close_shared_http_session)
133
+
131
134
  async def preview(self, parent: Tree | None = None):
132
135
  label = [
133
136
  f"[{OneColors.CYAN_b}]🌐 HTTPAction[/] '{self.name}'",
@@ -135,7 +138,7 @@ class HTTPAction(Action):
135
138
  f"\n[dim]URL:[/] {self.url}",
136
139
  ]
137
140
  if self.inject_last_result:
138
- label.append(f"\n[dim]Injects:[/] '{self.inject_last_result_as}'")
141
+ label.append(f"\n[dim]Injects:[/] '{self.inject_into}'")
139
142
  if self.retry_policy and self.retry_policy.enabled:
140
143
  label.append(
141
144
  f"\n[dim]↻ Retries:[/] {self.retry_policy.max_retries}x, "
@@ -33,8 +33,10 @@ async def cleanup():
33
33
  """
34
34
 
35
35
  GLOBAL_CONFIG = """\
36
- async def cleanup():
37
- print("🧹 Cleaning temp files...")
36
+ - key: C
37
+ description: Cleanup temp files
38
+ action: tasks.cleanup
39
+ aliases: [clean, cleanup]
38
40
  """
39
41
 
40
42
  console = Console(color_system="auto")
@@ -83,7 +83,7 @@ class BaseIOAction(BaseAction):
83
83
  raise NotImplementedError
84
84
 
85
85
  async def _resolve_input(self, kwargs: dict[str, Any]) -> str | bytes:
86
- last_result = kwargs.pop(self.inject_last_result_as, None)
86
+ last_result = kwargs.pop(self.inject_into, None)
87
87
 
88
88
  data = await self._read_stdin()
89
89
  if data:
@@ -168,7 +168,7 @@ class BaseIOAction(BaseAction):
168
168
  async def preview(self, parent: Tree | None = None):
169
169
  label = [f"[{OneColors.GREEN_b}]⚙ IOAction[/] '{self.name}'"]
170
170
  if self.inject_last_result:
171
- label.append(f" [dim](injects '{self.inject_last_result_as}')[/dim]")
171
+ label.append(f" [dim](injects '{self.inject_into}')[/dim]")
172
172
  if parent:
173
173
  parent.add("".join(label))
174
174
  else:
@@ -243,7 +243,7 @@ class ShellAction(BaseIOAction):
243
243
  async def preview(self, parent: Tree | None = None):
244
244
  label = [f"[{OneColors.GREEN_b}]⚙ ShellAction[/] '{self.name}'"]
245
245
  if self.inject_last_result:
246
- label.append(f" [dim](injects '{self.inject_last_result_as}')[/dim]")
246
+ label.append(f" [dim](injects '{self.inject_into}')[/dim]")
247
247
  if parent:
248
248
  parent.add("".join(label))
249
249
  else:
@@ -101,7 +101,7 @@ class MenuAction(BaseAction):
101
101
  prompt_message: str = "Select > ",
102
102
  default_selection: str = "",
103
103
  inject_last_result: bool = False,
104
- inject_last_result_as: str = "last_result",
104
+ inject_into: str = "last_result",
105
105
  console: Console | None = None,
106
106
  prompt_session: PromptSession | None = None,
107
107
  never_prompt: bool = False,
@@ -111,7 +111,7 @@ class MenuAction(BaseAction):
111
111
  super().__init__(
112
112
  name,
113
113
  inject_last_result=inject_last_result,
114
- inject_last_result_as=inject_last_result_as,
114
+ inject_into=inject_into,
115
115
  never_prompt=never_prompt,
116
116
  )
117
117
  self.menu_options = menu_options
@@ -168,15 +168,13 @@ class MenuAction(BaseAction):
168
168
  await self.hooks.trigger(HookType.BEFORE, context)
169
169
  key = effective_default
170
170
  if not self.never_prompt:
171
- console = self.console
172
- session = self.prompt_session
173
171
  table = self._build_table()
174
172
  key = await prompt_for_selection(
175
173
  self.menu_options.keys(),
176
174
  table,
177
175
  default_selection=self.default_selection,
178
- console=console,
179
- session=session,
176
+ console=self.console,
177
+ prompt_session=self.prompt_session,
180
178
  prompt_message=self.prompt_message,
181
179
  show_table=self.show_table,
182
180
  )
@@ -9,11 +9,10 @@ def should_prompt_user(
9
9
  ):
10
10
  """Determine whether to prompt the user for confirmation based on command and global options."""
11
11
  never_prompt = options.get("never_prompt", False, namespace)
12
- always_confirm = options.get("always_confirm", False, namespace)
13
12
  force_confirm = options.get("force_confirm", False, namespace)
14
13
  skip_confirm = options.get("skip_confirm", False, namespace)
15
14
 
16
15
  if never_prompt or skip_confirm:
17
16
  return False
18
17
 
19
- return confirm or always_confirm or force_confirm
18
+ return confirm or force_confirm
@@ -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: ...
@@ -10,7 +10,7 @@ class SelectFilesAction(BaseAction):
10
10
  suffix_filter: str | None = None,
11
11
  return_path: bool = True,
12
12
  console: Console | None = None,
13
- session: PromptSession | None = None,
13
+ prompt_session: PromptSession | None = None,
14
14
  ):
15
15
  super().__init__(name)
16
16
  self.directory = Path(directory).resolve()
@@ -20,7 +20,7 @@ class SelectFilesAction(BaseAction):
20
20
  self.style = style
21
21
  self.return_path = return_path
22
22
  self.console = console or Console()
23
- self.session = session or PromptSession()
23
+ self.prompt_session = prompt_session or PromptSession()
24
24
 
25
25
  async def _run(self, *args, **kwargs) -> Any:
26
26
  context = ExecutionContext(name=self.name, args=args, kwargs=kwargs, action=self)
@@ -49,7 +49,7 @@ class SelectFilesAction(BaseAction):
49
49
  options.keys(),
50
50
  table,
51
51
  console=self.console,
52
- session=self.session,
52
+ prompt_session=self.prompt_session,
53
53
  prompt_message=self.prompt_message,
54
54
  )
55
55
 
@@ -203,17 +203,17 @@ async def prompt_for_index(
203
203
  min_index: int = 0,
204
204
  default_selection: str = "",
205
205
  console: Console | None = None,
206
- session: PromptSession | None = None,
206
+ prompt_session: PromptSession | None = None,
207
207
  prompt_message: str = "Select an option > ",
208
208
  show_table: bool = True,
209
209
  ):
210
- session = session or PromptSession()
210
+ prompt_session = prompt_session or PromptSession()
211
211
  console = console or Console(color_system="auto")
212
212
 
213
213
  if show_table:
214
214
  console.print(table)
215
215
 
216
- selection = await session.prompt_async(
216
+ selection = await prompt_session.prompt_async(
217
217
  message=prompt_message,
218
218
  validator=int_range_validator(min_index, max_index),
219
219
  default=default_selection,
@@ -226,18 +226,18 @@ async def prompt_for_selection(
226
226
  table: Table,
227
227
  default_selection: str = "",
228
228
  console: Console | None = None,
229
- session: PromptSession | None = None,
229
+ prompt_session: PromptSession | None = None,
230
230
  prompt_message: str = "Select an option > ",
231
231
  show_table: bool = True,
232
232
  ) -> str:
233
233
  """Prompt the user to select a key from a set of options. Return the selected key."""
234
- session = session or PromptSession()
234
+ prompt_session = prompt_session or PromptSession()
235
235
  console = console or Console(color_system="auto")
236
236
 
237
237
  if show_table:
238
238
  console.print(table, justify="center")
239
239
 
240
- selected = await session.prompt_async(
240
+ selected = await prompt_session.prompt_async(
241
241
  message=prompt_message,
242
242
  validator=key_validator(keys),
243
243
  default=default_selection,
@@ -250,7 +250,7 @@ async def select_value_from_list(
250
250
  title: str,
251
251
  selections: Sequence[str],
252
252
  console: Console | None = None,
253
- session: PromptSession | None = None,
253
+ prompt_session: PromptSession | None = None,
254
254
  prompt_message: str = "Select an option > ",
255
255
  default_selection: str = "",
256
256
  columns: int = 4,
@@ -283,7 +283,7 @@ async def select_value_from_list(
283
283
  caption_style,
284
284
  highlight,
285
285
  )
286
- session = session or PromptSession()
286
+ prompt_session = prompt_session or PromptSession()
287
287
  console = console or Console(color_system="auto")
288
288
 
289
289
  selection_index = await prompt_for_index(
@@ -291,7 +291,7 @@ async def select_value_from_list(
291
291
  table,
292
292
  default_selection=default_selection,
293
293
  console=console,
294
- session=session,
294
+ prompt_session=prompt_session,
295
295
  prompt_message=prompt_message,
296
296
  )
297
297
 
@@ -302,12 +302,12 @@ async def select_key_from_dict(
302
302
  selections: dict[str, SelectionOption],
303
303
  table: Table,
304
304
  console: Console | None = None,
305
- session: PromptSession | None = None,
305
+ prompt_session: PromptSession | None = None,
306
306
  prompt_message: str = "Select an option > ",
307
307
  default_selection: str = "",
308
308
  ) -> Any:
309
309
  """Prompt for a key from a dict, returns the key."""
310
- session = session or PromptSession()
310
+ prompt_session = prompt_session or PromptSession()
311
311
  console = console or Console(color_system="auto")
312
312
 
313
313
  console.print(table)
@@ -317,7 +317,7 @@ async def select_key_from_dict(
317
317
  table,
318
318
  default_selection=default_selection,
319
319
  console=console,
320
- session=session,
320
+ prompt_session=prompt_session,
321
321
  prompt_message=prompt_message,
322
322
  )
323
323
 
@@ -326,12 +326,12 @@ async def select_value_from_dict(
326
326
  selections: dict[str, SelectionOption],
327
327
  table: Table,
328
328
  console: Console | None = None,
329
- session: PromptSession | None = None,
329
+ prompt_session: PromptSession | None = None,
330
330
  prompt_message: str = "Select an option > ",
331
331
  default_selection: str = "",
332
332
  ) -> Any:
333
333
  """Prompt for a key from a dict, but return the value."""
334
- session = session or PromptSession()
334
+ prompt_session = prompt_session or PromptSession()
335
335
  console = console or Console(color_system="auto")
336
336
 
337
337
  console.print(table)
@@ -341,7 +341,7 @@ async def select_value_from_dict(
341
341
  table,
342
342
  default_selection=default_selection,
343
343
  console=console,
344
- session=session,
344
+ prompt_session=prompt_session,
345
345
  prompt_message=prompt_message,
346
346
  )
347
347
 
@@ -352,7 +352,7 @@ async def get_selection_from_dict_menu(
352
352
  title: str,
353
353
  selections: dict[str, SelectionOption],
354
354
  console: Console | None = None,
355
- session: PromptSession | None = None,
355
+ prompt_session: PromptSession | None = None,
356
356
  prompt_message: str = "Select an option > ",
357
357
  default_selection: str = "",
358
358
  ):
@@ -366,7 +366,7 @@ async def get_selection_from_dict_menu(
366
366
  selections,
367
367
  table,
368
368
  console,
369
- session,
369
+ prompt_session,
370
370
  prompt_message,
371
371
  default_selection,
372
372
  )
@@ -26,24 +26,24 @@ class SelectionAction(BaseAction):
26
26
  def __init__(
27
27
  self,
28
28
  name: str,
29
- selections: list[str] | dict[str, SelectionOption],
29
+ selections: list[str] | set[str] | tuple[str, ...] | dict[str, SelectionOption],
30
30
  *,
31
31
  title: str = "Select an option",
32
32
  columns: int = 2,
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
- session: PromptSession | None = None,
39
+ prompt_session: PromptSession | None = None,
40
40
  never_prompt: bool = False,
41
41
  show_table: bool = True,
42
42
  ):
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
@@ -51,7 +51,7 @@ class SelectionAction(BaseAction):
51
51
  self.title = title
52
52
  self.columns = columns
53
53
  self.console = console or Console(color_system="auto")
54
- self.session = session or PromptSession()
54
+ self.prompt_session = prompt_session or PromptSession()
55
55
  self.default_selection = default_selection
56
56
  self.prompt_message = prompt_message
57
57
  self.show_table = show_table
@@ -61,9 +61,11 @@ class SelectionAction(BaseAction):
61
61
  return self._selections
62
62
 
63
63
  @selections.setter
64
- def selections(self, value: list[str] | dict[str, SelectionOption]):
65
- if isinstance(value, list):
66
- self._selections: list[str] | CaseInsensitiveDict = value
64
+ def selections(
65
+ self, value: list[str] | set[str] | tuple[str, ...] | dict[str, SelectionOption]
66
+ ):
67
+ if isinstance(value, (list, tuple, set)):
68
+ self._selections: list[str] | CaseInsensitiveDict = list(value)
67
69
  elif isinstance(value, dict):
68
70
  cid = CaseInsensitiveDict()
69
71
  cid.update(value)
@@ -123,7 +125,7 @@ class SelectionAction(BaseAction):
123
125
  table,
124
126
  default_selection=effective_default,
125
127
  console=self.console,
126
- session=self.session,
128
+ prompt_session=self.prompt_session,
127
129
  prompt_message=self.prompt_message,
128
130
  show_table=self.show_table,
129
131
  )
@@ -140,7 +142,7 @@ class SelectionAction(BaseAction):
140
142
  table,
141
143
  default_selection=effective_default,
142
144
  console=self.console,
143
- session=self.session,
145
+ prompt_session=self.prompt_session,
144
146
  prompt_message=self.prompt_message,
145
147
  show_table=self.show_table,
146
148
  )
@@ -0,0 +1 @@
1
+ __version__ = "0.1.20"
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "falyx"
3
- version = "0.1.18"
3
+ version = "0.1.20"
4
4
  description = "Reliable and introspectable async CLI action framework."
5
5
  authors = ["Roland Thomas Jr <roland@rtj.dev>"]
6
6
  license = "MIT"
Binary file
@@ -1 +0,0 @@
1
- __version__ = "0.1.18"
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