falyx 0.1.22__tar.gz → 0.1.24__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.
- {falyx-0.1.22 → falyx-0.1.24}/PKG-INFO +1 -1
- {falyx-0.1.22 → falyx-0.1.24}/falyx/__main__.py +3 -8
- {falyx-0.1.22 → falyx-0.1.24}/falyx/action.py +40 -22
- {falyx-0.1.22 → falyx-0.1.24}/falyx/action_factory.py +16 -3
- {falyx-0.1.22 → falyx-0.1.24}/falyx/bottom_bar.py +2 -2
- {falyx-0.1.22 → falyx-0.1.24}/falyx/command.py +10 -7
- falyx-0.1.24/falyx/config.py +222 -0
- falyx-0.1.24/falyx/config_schema.py +76 -0
- {falyx-0.1.22 → falyx-0.1.24}/falyx/context.py +16 -8
- {falyx-0.1.22 → falyx-0.1.24}/falyx/debug.py +2 -1
- {falyx-0.1.22 → falyx-0.1.24}/falyx/exceptions.py +3 -0
- {falyx-0.1.22 → falyx-0.1.24}/falyx/execution_registry.py +61 -14
- {falyx-0.1.22 → falyx-0.1.24}/falyx/falyx.py +108 -100
- {falyx-0.1.22 → falyx-0.1.24}/falyx/hook_manager.py +20 -3
- {falyx-0.1.22 → falyx-0.1.24}/falyx/hooks.py +12 -5
- {falyx-0.1.22 → falyx-0.1.24}/falyx/http_action.py +8 -7
- falyx-0.1.24/falyx/init.py +137 -0
- {falyx-0.1.22 → falyx-0.1.24}/falyx/io_action.py +34 -16
- falyx-0.1.24/falyx/logger.py +5 -0
- {falyx-0.1.22 → falyx-0.1.24}/falyx/menu_action.py +8 -2
- {falyx-0.1.22 → falyx-0.1.24}/falyx/options_manager.py +7 -3
- {falyx-0.1.22 → falyx-0.1.24}/falyx/parsers.py +2 -2
- falyx-0.1.24/falyx/prompt_utils.py +48 -0
- {falyx-0.1.22 → falyx-0.1.24}/falyx/protocols.py +2 -0
- {falyx-0.1.22 → falyx-0.1.24}/falyx/retry.py +23 -12
- {falyx-0.1.22 → falyx-0.1.24}/falyx/retry_utils.py +1 -0
- {falyx-0.1.22 → falyx-0.1.24}/falyx/select_file_action.py +40 -5
- {falyx-0.1.22 → falyx-0.1.24}/falyx/selection.py +9 -5
- {falyx-0.1.22 → falyx-0.1.24}/falyx/selection_action.py +22 -8
- {falyx-0.1.22 → falyx-0.1.24}/falyx/signal_action.py +2 -0
- {falyx-0.1.22 → falyx-0.1.24}/falyx/signals.py +3 -0
- {falyx-0.1.22 → falyx-0.1.24}/falyx/tagged_table.py +2 -1
- {falyx-0.1.22 → falyx-0.1.24}/falyx/utils.py +11 -39
- {falyx-0.1.22 → falyx-0.1.24}/falyx/validators.py +8 -7
- falyx-0.1.24/falyx/version.py +1 -0
- {falyx-0.1.22 → falyx-0.1.24}/pyproject.toml +3 -3
- falyx-0.1.22/falyx/config.py +0 -144
- falyx-0.1.22/falyx/init.py +0 -76
- falyx-0.1.22/falyx/prompt_utils.py +0 -18
- falyx-0.1.22/falyx/version.py +0 -1
- {falyx-0.1.22 → falyx-0.1.24}/LICENSE +0 -0
- {falyx-0.1.22 → falyx-0.1.24}/README.md +0 -0
- {falyx-0.1.22 → falyx-0.1.24}/falyx/.pytyped +0 -0
- {falyx-0.1.22 → falyx-0.1.24}/falyx/__init__.py +0 -0
- {falyx-0.1.22 → falyx-0.1.24}/falyx/themes/colors.py +0 -0
@@ -6,6 +6,7 @@ Licensed under the MIT License. See LICENSE file for details.
|
|
6
6
|
"""
|
7
7
|
|
8
8
|
import asyncio
|
9
|
+
import os
|
9
10
|
import sys
|
10
11
|
from argparse import Namespace
|
11
12
|
from pathlib import Path
|
@@ -14,7 +15,6 @@ from typing import Any
|
|
14
15
|
from falyx.config import loader
|
15
16
|
from falyx.falyx import Falyx
|
16
17
|
from falyx.parsers import FalyxParsers, get_arg_parsers
|
17
|
-
from falyx.themes.colors import OneColors
|
18
18
|
|
19
19
|
|
20
20
|
def find_falyx_config() -> Path | None:
|
@@ -23,6 +23,7 @@ def find_falyx_config() -> Path | None:
|
|
23
23
|
Path.cwd() / "falyx.toml",
|
24
24
|
Path.cwd() / ".falyx.yaml",
|
25
25
|
Path.cwd() / ".falyx.toml",
|
26
|
+
Path(os.environ.get("FALYX_CONFIG", "falyx.yaml")),
|
26
27
|
Path.home() / ".config" / "falyx" / "falyx.yaml",
|
27
28
|
Path.home() / ".config" / "falyx" / "falyx.toml",
|
28
29
|
Path.home() / ".falyx.yaml",
|
@@ -68,13 +69,7 @@ def run(args: Namespace) -> Any:
|
|
68
69
|
print("No Falyx config file found. Exiting.")
|
69
70
|
return None
|
70
71
|
|
71
|
-
flx =
|
72
|
-
title="🛠️ Config-Driven CLI",
|
73
|
-
cli_args=args,
|
74
|
-
columns=4,
|
75
|
-
prompt=[(OneColors.BLUE_b, "FALYX > ")],
|
76
|
-
)
|
77
|
-
flx.add_commands(loader(bootstrap_path))
|
72
|
+
flx: Falyx = loader(bootstrap_path)
|
78
73
|
return asyncio.run(flx.run())
|
79
74
|
|
80
75
|
|
@@ -4,7 +4,8 @@
|
|
4
4
|
Core action system for Falyx.
|
5
5
|
|
6
6
|
This module defines the building blocks for executable actions and workflows,
|
7
|
-
providing a structured way to compose, execute, recover, and manage sequences of
|
7
|
+
providing a structured way to compose, execute, recover, and manage sequences of
|
8
|
+
operations.
|
8
9
|
|
9
10
|
All actions are callable and follow a unified signature:
|
10
11
|
result = action(*args, **kwargs)
|
@@ -14,7 +15,8 @@ Core guarantees:
|
|
14
15
|
- Consistent timing and execution context tracking for each run.
|
15
16
|
- Unified, predictable result handling and error propagation.
|
16
17
|
- Optional last_result injection to enable flexible, data-driven workflows.
|
17
|
-
- Built-in support for retries, rollbacks, parallel groups, chaining, and fallback
|
18
|
+
- Built-in support for retries, rollbacks, parallel groups, chaining, and fallback
|
19
|
+
recovery.
|
18
20
|
|
19
21
|
Key components:
|
20
22
|
- Action: wraps a function or coroutine into a standard executable unit.
|
@@ -43,10 +45,11 @@ from falyx.debug import register_debug_hooks
|
|
43
45
|
from falyx.exceptions import EmptyChainError
|
44
46
|
from falyx.execution_registry import ExecutionRegistry as er
|
45
47
|
from falyx.hook_manager import Hook, HookManager, HookType
|
48
|
+
from falyx.logger import logger
|
46
49
|
from falyx.options_manager import OptionsManager
|
47
50
|
from falyx.retry import RetryHandler, RetryPolicy
|
48
51
|
from falyx.themes.colors import OneColors
|
49
|
-
from falyx.utils import ensure_async
|
52
|
+
from falyx.utils import ensure_async
|
50
53
|
|
51
54
|
|
52
55
|
class BaseAction(ABC):
|
@@ -55,7 +58,8 @@ class BaseAction(ABC):
|
|
55
58
|
complex actions like `ChainedAction` or `ActionGroup`. They can also
|
56
59
|
be run independently or as part of Falyx.
|
57
60
|
|
58
|
-
inject_last_result (bool): Whether to inject the previous action's result
|
61
|
+
inject_last_result (bool): Whether to inject the previous action's result
|
62
|
+
into kwargs.
|
59
63
|
inject_into (str): The name of the kwarg key to inject the result as
|
60
64
|
(default: 'last_result').
|
61
65
|
_requires_injection (bool): Whether the action requires input injection.
|
@@ -104,7 +108,9 @@ class BaseAction(ABC):
|
|
104
108
|
self.shared_context = shared_context
|
105
109
|
|
106
110
|
def get_option(self, option_name: str, default: Any = None) -> Any:
|
107
|
-
"""
|
111
|
+
"""
|
112
|
+
Resolve an option from the OptionsManager if present, otherwise use the fallback.
|
113
|
+
"""
|
108
114
|
if self.options_manager:
|
109
115
|
return self.options_manager.get(option_name, default)
|
110
116
|
return default
|
@@ -288,8 +294,10 @@ class Action(BaseAction):
|
|
288
294
|
|
289
295
|
def __str__(self):
|
290
296
|
return (
|
291
|
-
f"Action(name={self.name!r}, action=
|
292
|
-
f"
|
297
|
+
f"Action(name={self.name!r}, action="
|
298
|
+
f"{getattr(self._action, '__name__', repr(self._action))}, "
|
299
|
+
f"args={self.args!r}, kwargs={self.kwargs!r}, "
|
300
|
+
f"retry={self.retry_policy.enabled})"
|
293
301
|
)
|
294
302
|
|
295
303
|
|
@@ -309,7 +317,7 @@ class LiteralInputAction(Action):
|
|
309
317
|
def __init__(self, value: Any):
|
310
318
|
self._value = value
|
311
319
|
|
312
|
-
async def literal(*
|
320
|
+
async def literal(*_, **__):
|
313
321
|
return value
|
314
322
|
|
315
323
|
super().__init__("Input", literal)
|
@@ -333,14 +341,16 @@ class LiteralInputAction(Action):
|
|
333
341
|
|
334
342
|
class FallbackAction(Action):
|
335
343
|
"""
|
336
|
-
FallbackAction provides a default value if the previous action failed or
|
344
|
+
FallbackAction provides a default value if the previous action failed or
|
345
|
+
returned None.
|
337
346
|
|
338
347
|
It injects the last result and checks:
|
339
348
|
- If last_result is not None, it passes it through unchanged.
|
340
349
|
- If last_result is None (e.g., due to failure), it replaces it with a fallback value.
|
341
350
|
|
342
351
|
Used in ChainedAction pipelines to gracefully recover from errors or missing data.
|
343
|
-
When activated, it consumes the preceding error and allows the chain to continue
|
352
|
+
When activated, it consumes the preceding error and allows the chain to continue
|
353
|
+
normally.
|
344
354
|
|
345
355
|
Args:
|
346
356
|
fallback (Any): The fallback value to use if last_result is None.
|
@@ -413,16 +423,19 @@ class ChainedAction(BaseAction, ActionListMixin):
|
|
413
423
|
- Rolls back all previously executed actions if a failure occurs.
|
414
424
|
- Handles literal values with LiteralInputAction.
|
415
425
|
|
416
|
-
Best used for defining robust, ordered workflows where each step can depend on
|
426
|
+
Best used for defining robust, ordered workflows where each step can depend on
|
427
|
+
previous results.
|
417
428
|
|
418
429
|
Args:
|
419
430
|
name (str): Name of the chain.
|
420
431
|
actions (list): List of actions or literals to execute.
|
421
432
|
hooks (HookManager, optional): Hooks for lifecycle events.
|
422
|
-
inject_last_result (bool, optional): Whether to inject last results into kwargs
|
433
|
+
inject_last_result (bool, optional): Whether to inject last results into kwargs
|
434
|
+
by default.
|
423
435
|
inject_into (str, optional): Key name for injection.
|
424
436
|
auto_inject (bool, optional): Auto-enable injection for subsequent actions.
|
425
|
-
return_list (bool, optional): Whether to return a list of all results. False
|
437
|
+
return_list (bool, optional): Whether to return a list of all results. False
|
438
|
+
returns the last result.
|
426
439
|
"""
|
427
440
|
|
428
441
|
def __init__(
|
@@ -468,7 +481,7 @@ class ChainedAction(BaseAction, ActionListMixin):
|
|
468
481
|
if not self.actions:
|
469
482
|
raise EmptyChainError(f"[{self.name}] No actions to execute.")
|
470
483
|
|
471
|
-
shared_context = SharedContext(name=self.name)
|
484
|
+
shared_context = SharedContext(name=self.name, action=self)
|
472
485
|
if self.shared_context:
|
473
486
|
shared_context.add_result(self.shared_context.last_result())
|
474
487
|
updated_kwargs = self._maybe_inject_last_result(kwargs)
|
@@ -503,7 +516,8 @@ class ChainedAction(BaseAction, ActionListMixin):
|
|
503
516
|
self.actions[index + 1], FallbackAction
|
504
517
|
):
|
505
518
|
logger.warning(
|
506
|
-
"[%s] ⚠️ Fallback triggered: %s, recovering with fallback
|
519
|
+
"[%s] ⚠️ Fallback triggered: %s, recovering with fallback "
|
520
|
+
"'%s'.",
|
507
521
|
self.name,
|
508
522
|
error,
|
509
523
|
self.actions[index + 1].name,
|
@@ -579,7 +593,8 @@ class ChainedAction(BaseAction, ActionListMixin):
|
|
579
593
|
|
580
594
|
def __str__(self):
|
581
595
|
return (
|
582
|
-
f"ChainedAction(name={self.name!r},
|
596
|
+
f"ChainedAction(name={self.name!r}, "
|
597
|
+
f"actions={[a.name for a in self.actions]!r}, "
|
583
598
|
f"auto_inject={self.auto_inject}, return_list={self.return_list})"
|
584
599
|
)
|
585
600
|
|
@@ -613,7 +628,8 @@ class ActionGroup(BaseAction, ActionListMixin):
|
|
613
628
|
name (str): Name of the chain.
|
614
629
|
actions (list): List of actions or literals to execute.
|
615
630
|
hooks (HookManager, optional): Hooks for lifecycle events.
|
616
|
-
inject_last_result (bool, optional): Whether to inject last results into kwargs
|
631
|
+
inject_last_result (bool, optional): Whether to inject last results into kwargs
|
632
|
+
by default.
|
617
633
|
inject_into (str, optional): Key name for injection.
|
618
634
|
"""
|
619
635
|
|
@@ -643,7 +659,8 @@ class ActionGroup(BaseAction, ActionListMixin):
|
|
643
659
|
return Action(name=action.__name__, action=action)
|
644
660
|
else:
|
645
661
|
raise TypeError(
|
646
|
-
|
662
|
+
"ActionGroup only accepts BaseAction or callable, got "
|
663
|
+
f"{type(action).__name__}"
|
647
664
|
)
|
648
665
|
|
649
666
|
def add_action(self, action: BaseAction | Any) -> None:
|
@@ -653,7 +670,7 @@ class ActionGroup(BaseAction, ActionListMixin):
|
|
653
670
|
action.register_teardown(self.hooks)
|
654
671
|
|
655
672
|
async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]:
|
656
|
-
shared_context = SharedContext(name=self.name, is_parallel=True)
|
673
|
+
shared_context = SharedContext(name=self.name, action=self, is_parallel=True)
|
657
674
|
if self.shared_context:
|
658
675
|
shared_context.set_shared_result(self.shared_context.last_result())
|
659
676
|
updated_kwargs = self._maybe_inject_last_result(kwargs)
|
@@ -721,8 +738,8 @@ class ActionGroup(BaseAction, ActionListMixin):
|
|
721
738
|
|
722
739
|
def __str__(self):
|
723
740
|
return (
|
724
|
-
f"ActionGroup(name={self.name!r}, actions={[a.name for a in self.actions]!r},
|
725
|
-
f"inject_last_result={self.inject_last_result})"
|
741
|
+
f"ActionGroup(name={self.name!r}, actions={[a.name for a in self.actions]!r},"
|
742
|
+
f" inject_last_result={self.inject_last_result})"
|
726
743
|
)
|
727
744
|
|
728
745
|
|
@@ -831,6 +848,7 @@ class ProcessAction(BaseAction):
|
|
831
848
|
|
832
849
|
def __str__(self) -> str:
|
833
850
|
return (
|
834
|
-
f"ProcessAction(name={self.name!r},
|
851
|
+
f"ProcessAction(name={self.name!r}, "
|
852
|
+
f"action={getattr(self.action, '__name__', repr(self.action))}, "
|
835
853
|
f"args={self.args!r}, kwargs={self.kwargs!r})"
|
836
854
|
)
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
2
|
+
"""action_factory.py"""
|
1
3
|
from typing import Any
|
2
4
|
|
3
5
|
from rich.tree import Tree
|
@@ -6,6 +8,7 @@ from falyx.action import BaseAction
|
|
6
8
|
from falyx.context import ExecutionContext
|
7
9
|
from falyx.execution_registry import ExecutionRegistry as er
|
8
10
|
from falyx.hook_manager import HookType
|
11
|
+
from falyx.logger import logger
|
9
12
|
from falyx.protocols import ActionFactoryProtocol
|
10
13
|
from falyx.themes.colors import OneColors
|
11
14
|
|
@@ -32,7 +35,7 @@ class ActionFactoryAction(BaseAction):
|
|
32
35
|
inject_last_result: bool = False,
|
33
36
|
inject_into: str = "last_result",
|
34
37
|
preview_args: tuple[Any, ...] = (),
|
35
|
-
preview_kwargs: dict[str, Any] =
|
38
|
+
preview_kwargs: dict[str, Any] | None = None,
|
36
39
|
):
|
37
40
|
super().__init__(
|
38
41
|
name=name,
|
@@ -41,7 +44,7 @@ class ActionFactoryAction(BaseAction):
|
|
41
44
|
)
|
42
45
|
self.factory = factory
|
43
46
|
self.preview_args = preview_args
|
44
|
-
self.preview_kwargs = preview_kwargs
|
47
|
+
self.preview_kwargs = preview_kwargs or {}
|
45
48
|
|
46
49
|
async def _run(self, *args, **kwargs) -> Any:
|
47
50
|
updated_kwargs = self._maybe_inject_last_result(kwargs)
|
@@ -57,10 +60,20 @@ class ActionFactoryAction(BaseAction):
|
|
57
60
|
generated_action = self.factory(*args, **updated_kwargs)
|
58
61
|
if not isinstance(generated_action, BaseAction):
|
59
62
|
raise TypeError(
|
60
|
-
f"[{self.name}] Factory must return a BaseAction, got
|
63
|
+
f"[{self.name}] Factory must return a BaseAction, got "
|
64
|
+
f"{type(generated_action).__name__}"
|
61
65
|
)
|
62
66
|
if self.shared_context:
|
63
67
|
generated_action.set_shared_context(self.shared_context)
|
68
|
+
if hasattr(generated_action, "register_teardown") and callable(
|
69
|
+
generated_action.register_teardown
|
70
|
+
):
|
71
|
+
generated_action.register_teardown(self.shared_context.action.hooks)
|
72
|
+
logger.debug(
|
73
|
+
"[%s] Registered teardown for %s",
|
74
|
+
self.name,
|
75
|
+
generated_action.name,
|
76
|
+
)
|
64
77
|
if self.options_manager:
|
65
78
|
generated_action.set_options_manager(self.options_manager)
|
66
79
|
context.result = await generated_action(*args, **kwargs)
|
@@ -146,7 +146,7 @@ class BottomBar:
|
|
146
146
|
for k in (key.upper(), key.lower()):
|
147
147
|
|
148
148
|
@self.key_bindings.add(k)
|
149
|
-
def _(
|
149
|
+
def _(_):
|
150
150
|
toggle_state()
|
151
151
|
|
152
152
|
def add_toggle_from_option(
|
@@ -204,6 +204,6 @@ class BottomBar:
|
|
204
204
|
"""Render the bottom bar."""
|
205
205
|
lines = []
|
206
206
|
for chunk in chunks(self._named_items.values(), self.columns):
|
207
|
-
lines.extend(
|
207
|
+
lines.extend(list(chunk))
|
208
208
|
lines.append(lambda: HTML("\n"))
|
209
209
|
return merge_formatted_text([fn() for fn in lines[:-1]])
|
@@ -33,12 +33,13 @@ from falyx.exceptions import FalyxError
|
|
33
33
|
from falyx.execution_registry import ExecutionRegistry as er
|
34
34
|
from falyx.hook_manager import HookManager, HookType
|
35
35
|
from falyx.io_action import BaseIOAction
|
36
|
+
from falyx.logger import logger
|
36
37
|
from falyx.options_manager import OptionsManager
|
37
|
-
from falyx.prompt_utils import should_prompt_user
|
38
|
+
from falyx.prompt_utils import confirm_async, should_prompt_user
|
38
39
|
from falyx.retry import RetryPolicy
|
39
40
|
from falyx.retry_utils import enable_retries_recursively
|
40
41
|
from falyx.themes.colors import OneColors
|
41
|
-
from falyx.utils import _noop,
|
42
|
+
from falyx.utils import _noop, ensure_async
|
42
43
|
|
43
44
|
console = Console(color_system="auto")
|
44
45
|
|
@@ -134,7 +135,7 @@ class Command(BaseModel):
|
|
134
135
|
return ensure_async(action)
|
135
136
|
raise TypeError("Action must be a callable or an instance of BaseAction")
|
136
137
|
|
137
|
-
def model_post_init(self,
|
138
|
+
def model_post_init(self, _: Any) -> None:
|
138
139
|
"""Post-initialization to set up the action and hooks."""
|
139
140
|
if self.retry and isinstance(self.action, Action):
|
140
141
|
self.action.enable_retry()
|
@@ -142,14 +143,16 @@ class Command(BaseModel):
|
|
142
143
|
self.action.set_retry_policy(self.retry_policy)
|
143
144
|
elif self.retry:
|
144
145
|
logger.warning(
|
145
|
-
|
146
|
+
"[Command:%s] Retry requested, but action is not an Action instance.",
|
147
|
+
self.key,
|
146
148
|
)
|
147
149
|
if self.retry_all and isinstance(self.action, BaseAction):
|
148
150
|
self.retry_policy.enabled = True
|
149
151
|
enable_retries_recursively(self.action, self.retry_policy)
|
150
152
|
elif self.retry_all:
|
151
153
|
logger.warning(
|
152
|
-
|
154
|
+
"[Command:%s] Retry all requested, but action is not a BaseAction.",
|
155
|
+
self.key,
|
153
156
|
)
|
154
157
|
|
155
158
|
if self.logging_hooks and isinstance(self.action, BaseAction):
|
@@ -201,7 +204,7 @@ class Command(BaseModel):
|
|
201
204
|
if self.preview_before_confirm:
|
202
205
|
await self.preview()
|
203
206
|
if not await confirm_async(self.confirmation_prompt):
|
204
|
-
logger.info(
|
207
|
+
logger.info("[Command:%s] ❌ Cancelled by user.", self.key)
|
205
208
|
raise FalyxError(f"[Command:{self.key}] Cancelled by confirmation.")
|
206
209
|
|
207
210
|
context.start_timer()
|
@@ -288,7 +291,7 @@ class Command(BaseModel):
|
|
288
291
|
if self.help_text:
|
289
292
|
console.print(f"[dim]💡 {self.help_text}[/dim]")
|
290
293
|
console.print(
|
291
|
-
f"[{OneColors.DARK_RED}]⚠️
|
294
|
+
f"[{OneColors.DARK_RED}]⚠️ No preview available for this action.[/]"
|
292
295
|
)
|
293
296
|
|
294
297
|
def __str__(self) -> str:
|
@@ -0,0 +1,222 @@
|
|
1
|
+
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
2
|
+
"""config.py
|
3
|
+
Configuration loader for Falyx CLI commands."""
|
4
|
+
from __future__ import annotations
|
5
|
+
|
6
|
+
import importlib
|
7
|
+
import sys
|
8
|
+
from pathlib import Path
|
9
|
+
from typing import Any, Callable
|
10
|
+
|
11
|
+
import toml
|
12
|
+
import yaml
|
13
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
14
|
+
from rich.console import Console
|
15
|
+
|
16
|
+
from falyx.action import Action, BaseAction
|
17
|
+
from falyx.command import Command
|
18
|
+
from falyx.falyx import Falyx
|
19
|
+
from falyx.logger import logger
|
20
|
+
from falyx.retry import RetryPolicy
|
21
|
+
from falyx.themes.colors import OneColors
|
22
|
+
|
23
|
+
console = Console(color_system="auto")
|
24
|
+
|
25
|
+
|
26
|
+
def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command:
|
27
|
+
if isinstance(obj, (BaseAction, Command)):
|
28
|
+
return obj
|
29
|
+
elif callable(obj):
|
30
|
+
return Action(name=name or getattr(obj, "__name__", "unnamed"), action=obj)
|
31
|
+
else:
|
32
|
+
raise TypeError(
|
33
|
+
f"Cannot wrap object of type '{type(obj).__name__}'. "
|
34
|
+
"Expected a function or BaseAction."
|
35
|
+
)
|
36
|
+
|
37
|
+
|
38
|
+
def import_action(dotted_path: str) -> Any:
|
39
|
+
"""Dynamically imports a callable from a dotted path like 'my.module.func'."""
|
40
|
+
module_path, _, attr = dotted_path.rpartition(".")
|
41
|
+
if not module_path:
|
42
|
+
console.print(f"[{OneColors.DARK_RED}]❌ Invalid action path:[/] {dotted_path}")
|
43
|
+
sys.exit(1)
|
44
|
+
try:
|
45
|
+
module = importlib.import_module(module_path)
|
46
|
+
except ModuleNotFoundError as error:
|
47
|
+
logger.error("Failed to import module '%s': %s", module_path, error)
|
48
|
+
console.print(
|
49
|
+
f"[{OneColors.DARK_RED}]❌ Could not import '{dotted_path}': {error}[/]\n"
|
50
|
+
f"[{OneColors.COMMENT_GREY}]Ensure the module is installed and discoverable "
|
51
|
+
"via PYTHONPATH."
|
52
|
+
)
|
53
|
+
sys.exit(1)
|
54
|
+
try:
|
55
|
+
action = getattr(module, attr)
|
56
|
+
except AttributeError as error:
|
57
|
+
logger.error(
|
58
|
+
"Module '%s' does not have attribute '%s': %s", module_path, attr, error
|
59
|
+
)
|
60
|
+
console.print(
|
61
|
+
f"[{OneColors.DARK_RED}]❌ Module '{module_path}' has no attribute "
|
62
|
+
f"'{attr}': {error}[/]"
|
63
|
+
)
|
64
|
+
sys.exit(1)
|
65
|
+
return action
|
66
|
+
|
67
|
+
|
68
|
+
class RawCommand(BaseModel):
|
69
|
+
"""Raw command model for Falyx CLI configuration."""
|
70
|
+
|
71
|
+
key: str
|
72
|
+
description: str
|
73
|
+
action: str
|
74
|
+
|
75
|
+
args: tuple[Any, ...] = ()
|
76
|
+
kwargs: dict[str, Any] = {}
|
77
|
+
aliases: list[str] = []
|
78
|
+
tags: list[str] = []
|
79
|
+
style: str = OneColors.WHITE
|
80
|
+
|
81
|
+
confirm: bool = False
|
82
|
+
confirm_message: str = "Are you sure?"
|
83
|
+
preview_before_confirm: bool = True
|
84
|
+
|
85
|
+
spinner: bool = False
|
86
|
+
spinner_message: str = "Processing..."
|
87
|
+
spinner_type: str = "dots"
|
88
|
+
spinner_style: str = OneColors.CYAN
|
89
|
+
spinner_kwargs: dict[str, Any] = {}
|
90
|
+
|
91
|
+
before_hooks: list[Callable] = []
|
92
|
+
success_hooks: list[Callable] = []
|
93
|
+
error_hooks: list[Callable] = []
|
94
|
+
after_hooks: list[Callable] = []
|
95
|
+
teardown_hooks: list[Callable] = []
|
96
|
+
|
97
|
+
logging_hooks: bool = False
|
98
|
+
retry: bool = False
|
99
|
+
retry_all: bool = False
|
100
|
+
retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
|
101
|
+
requires_input: bool | None = None
|
102
|
+
hidden: bool = False
|
103
|
+
help_text: str = ""
|
104
|
+
|
105
|
+
@field_validator("retry_policy")
|
106
|
+
@classmethod
|
107
|
+
def validate_retry_policy(cls, value: dict | RetryPolicy) -> RetryPolicy:
|
108
|
+
if isinstance(value, RetryPolicy):
|
109
|
+
return value
|
110
|
+
if not isinstance(value, dict):
|
111
|
+
raise ValueError("retry_policy must be a dictionary.")
|
112
|
+
return RetryPolicy(**value)
|
113
|
+
|
114
|
+
|
115
|
+
def convert_commands(raw_commands: list[dict[str, Any]]) -> list[Command]:
|
116
|
+
commands = []
|
117
|
+
for entry in raw_commands:
|
118
|
+
raw_command = RawCommand(**entry)
|
119
|
+
commands.append(
|
120
|
+
Command.model_validate(
|
121
|
+
{
|
122
|
+
**raw_command.model_dump(exclude={"action"}),
|
123
|
+
"action": wrap_if_needed(
|
124
|
+
import_action(raw_command.action), name=raw_command.description
|
125
|
+
),
|
126
|
+
}
|
127
|
+
)
|
128
|
+
)
|
129
|
+
return commands
|
130
|
+
|
131
|
+
|
132
|
+
class FalyxConfig(BaseModel):
|
133
|
+
"""Falyx CLI configuration model."""
|
134
|
+
|
135
|
+
title: str = "Falyx CLI"
|
136
|
+
prompt: str | list[tuple[str, str]] | list[list[str]] = [
|
137
|
+
(OneColors.BLUE_b, "FALYX > ")
|
138
|
+
]
|
139
|
+
columns: int = 4
|
140
|
+
welcome_message: str = ""
|
141
|
+
exit_message: str = ""
|
142
|
+
commands: list[Command] | list[dict] = []
|
143
|
+
|
144
|
+
@model_validator(mode="after")
|
145
|
+
def validate_prompt_format(self) -> FalyxConfig:
|
146
|
+
if isinstance(self.prompt, list):
|
147
|
+
for pair in self.prompt:
|
148
|
+
if not isinstance(pair, (list, tuple)) or len(pair) != 2:
|
149
|
+
raise ValueError(
|
150
|
+
"Prompt list must contain 2-element (style, text) pairs"
|
151
|
+
)
|
152
|
+
return self
|
153
|
+
|
154
|
+
def to_falyx(self) -> Falyx:
|
155
|
+
flx = Falyx(
|
156
|
+
title=self.title,
|
157
|
+
prompt=self.prompt, # type: ignore[arg-type]
|
158
|
+
columns=self.columns,
|
159
|
+
welcome_message=self.welcome_message,
|
160
|
+
exit_message=self.exit_message,
|
161
|
+
)
|
162
|
+
flx.add_commands(self.commands)
|
163
|
+
return flx
|
164
|
+
|
165
|
+
|
166
|
+
def loader(file_path: Path | str) -> Falyx:
|
167
|
+
"""
|
168
|
+
Load Falyx CLI configuration from a YAML or TOML file.
|
169
|
+
|
170
|
+
The file should contain a dictionary with a list of commands.
|
171
|
+
|
172
|
+
Each command should be defined as a dictionary with at least:
|
173
|
+
- key: a unique single-character key
|
174
|
+
- description: short description
|
175
|
+
- action: dotted import path to the action function/class
|
176
|
+
|
177
|
+
Args:
|
178
|
+
file_path (str): Path to the config file (YAML or TOML).
|
179
|
+
|
180
|
+
Returns:
|
181
|
+
Falyx: An instance of the Falyx CLI with loaded commands.
|
182
|
+
|
183
|
+
Raises:
|
184
|
+
ValueError: If the file format is unsupported or file cannot be parsed.
|
185
|
+
"""
|
186
|
+
if isinstance(file_path, (str, Path)):
|
187
|
+
path = Path(file_path)
|
188
|
+
else:
|
189
|
+
raise TypeError("file_path must be a string or Path object.")
|
190
|
+
|
191
|
+
if not path.is_file():
|
192
|
+
raise FileNotFoundError(f"No such config file: {file_path}")
|
193
|
+
|
194
|
+
suffix = path.suffix
|
195
|
+
with path.open("r", encoding="UTF-8") as config_file:
|
196
|
+
if suffix in (".yaml", ".yml"):
|
197
|
+
raw_config = yaml.safe_load(config_file)
|
198
|
+
elif suffix == ".toml":
|
199
|
+
raw_config = toml.load(config_file)
|
200
|
+
else:
|
201
|
+
raise ValueError(f"Unsupported config format: {suffix}")
|
202
|
+
|
203
|
+
if not isinstance(raw_config, dict):
|
204
|
+
raise ValueError(
|
205
|
+
"Configuration file must contain a dictionary with a list of commands.\n"
|
206
|
+
"Example:\n"
|
207
|
+
"title: 'My CLI'\n"
|
208
|
+
"commands:\n"
|
209
|
+
" - key: 'a'\n"
|
210
|
+
" description: 'Example command'\n"
|
211
|
+
" action: 'my_module.my_function'"
|
212
|
+
)
|
213
|
+
|
214
|
+
commands = convert_commands(raw_config["commands"])
|
215
|
+
return FalyxConfig(
|
216
|
+
title=raw_config.get("title", f"[{OneColors.BLUE_b}]Falyx CLI"),
|
217
|
+
prompt=raw_config.get("prompt", [(OneColors.BLUE_b, "FALYX > ")]),
|
218
|
+
columns=raw_config.get("columns", 4),
|
219
|
+
welcome_message=raw_config.get("welcome_message", ""),
|
220
|
+
exit_message=raw_config.get("exit_message", ""),
|
221
|
+
commands=commands,
|
222
|
+
).to_falyx()
|
@@ -0,0 +1,76 @@
|
|
1
|
+
FALYX_CONFIG_SCHEMA = {
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
3
|
+
"title": "Falyx CLI Config",
|
4
|
+
"type": "object",
|
5
|
+
"properties": {
|
6
|
+
"title": {"type": "string", "description": "Title shown at top of menu"},
|
7
|
+
"prompt": {
|
8
|
+
"oneOf": [
|
9
|
+
{"type": "string"},
|
10
|
+
{
|
11
|
+
"type": "array",
|
12
|
+
"items": {
|
13
|
+
"type": "array",
|
14
|
+
"prefixItems": [
|
15
|
+
{
|
16
|
+
"type": "string",
|
17
|
+
"description": "Style string (e.g., 'bold #ff0000 italic')",
|
18
|
+
},
|
19
|
+
{"type": "string", "description": "Text content"},
|
20
|
+
],
|
21
|
+
"minItems": 2,
|
22
|
+
"maxItems": 2,
|
23
|
+
},
|
24
|
+
},
|
25
|
+
]
|
26
|
+
},
|
27
|
+
"columns": {
|
28
|
+
"type": "integer",
|
29
|
+
"minimum": 1,
|
30
|
+
"description": "Number of menu columns",
|
31
|
+
},
|
32
|
+
"welcome_message": {"type": "string"},
|
33
|
+
"exit_message": {"type": "string"},
|
34
|
+
"commands": {
|
35
|
+
"type": "array",
|
36
|
+
"items": {
|
37
|
+
"type": "object",
|
38
|
+
"required": ["key", "description", "action"],
|
39
|
+
"properties": {
|
40
|
+
"key": {"type": "string", "minLength": 1},
|
41
|
+
"description": {"type": "string"},
|
42
|
+
"action": {
|
43
|
+
"type": "string",
|
44
|
+
"pattern": "^[a-zA-Z_][a-zA-Z0-9_]*(\\.[a-zA-Z_][a-zA-Z0-9_]*)+$",
|
45
|
+
"description": "Dotted import path (e.g., mymodule.task)",
|
46
|
+
},
|
47
|
+
"args": {"type": "array"},
|
48
|
+
"kwargs": {"type": "object"},
|
49
|
+
"aliases": {"type": "array", "items": {"type": "string"}},
|
50
|
+
"tags": {"type": "array", "items": {"type": "string"}},
|
51
|
+
"style": {"type": "string"},
|
52
|
+
"confirm": {"type": "boolean"},
|
53
|
+
"confirm_message": {"type": "string"},
|
54
|
+
"preview_before_confirm": {"type": "boolean"},
|
55
|
+
"spinner": {"type": "boolean"},
|
56
|
+
"spinner_message": {"type": "string"},
|
57
|
+
"spinner_type": {"type": "string"},
|
58
|
+
"spinner_style": {"type": "string"},
|
59
|
+
"logging_hooks": {"type": "boolean"},
|
60
|
+
"retry": {"type": "boolean"},
|
61
|
+
"retry_all": {"type": "boolean"},
|
62
|
+
"retry_policy": {
|
63
|
+
"type": "object",
|
64
|
+
"properties": {
|
65
|
+
"enabled": {"type": "boolean"},
|
66
|
+
"max_retries": {"type": "integer"},
|
67
|
+
"delay": {"type": "number"},
|
68
|
+
"backoff": {"type": "number"},
|
69
|
+
},
|
70
|
+
},
|
71
|
+
},
|
72
|
+
},
|
73
|
+
},
|
74
|
+
},
|
75
|
+
"required": ["commands"],
|
76
|
+
}
|