falyx 0.1.22__py3-none-any.whl → 0.1.24__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- falyx/__main__.py +3 -8
- falyx/action.py +40 -22
- falyx/action_factory.py +16 -3
- falyx/bottom_bar.py +2 -2
- falyx/command.py +10 -7
- falyx/config.py +134 -56
- falyx/config_schema.py +76 -0
- falyx/context.py +16 -8
- falyx/debug.py +2 -1
- falyx/exceptions.py +3 -0
- falyx/execution_registry.py +61 -14
- falyx/falyx.py +108 -100
- falyx/hook_manager.py +20 -3
- falyx/hooks.py +12 -5
- falyx/http_action.py +8 -7
- falyx/init.py +83 -22
- falyx/io_action.py +34 -16
- falyx/logger.py +5 -0
- falyx/menu_action.py +8 -2
- falyx/options_manager.py +7 -3
- falyx/parsers.py +2 -2
- falyx/prompt_utils.py +31 -1
- falyx/protocols.py +2 -0
- falyx/retry.py +23 -12
- falyx/retry_utils.py +1 -0
- falyx/select_file_action.py +40 -5
- falyx/selection.py +9 -5
- falyx/selection_action.py +22 -8
- falyx/signal_action.py +2 -0
- falyx/signals.py +3 -0
- falyx/tagged_table.py +2 -1
- falyx/utils.py +11 -39
- falyx/validators.py +8 -7
- falyx/version.py +1 -1
- {falyx-0.1.22.dist-info → falyx-0.1.24.dist-info}/METADATA +1 -1
- falyx-0.1.24.dist-info/RECORD +42 -0
- falyx-0.1.22.dist-info/RECORD +0 -40
- {falyx-0.1.22.dist-info → falyx-0.1.24.dist-info}/LICENSE +0 -0
- {falyx-0.1.22.dist-info → falyx-0.1.24.dist-info}/WHEEL +0 -0
- {falyx-0.1.22.dist-info → falyx-0.1.24.dist-info}/entry_points.txt +0 -0
falyx/init.py
CHANGED
@@ -1,30 +1,89 @@
|
|
1
1
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
2
|
+
"""init.py"""
|
2
3
|
from pathlib import Path
|
3
4
|
|
4
5
|
from rich.console import Console
|
5
6
|
|
6
7
|
TEMPLATE_TASKS = """\
|
7
|
-
|
8
|
-
|
9
|
-
return "Build complete!"
|
8
|
+
# This file is used by falyx.yaml to define CLI actions.
|
9
|
+
# You can run: falyx run [key] or falyx list to see available commands.
|
10
10
|
|
11
|
-
|
12
|
-
|
13
|
-
|
11
|
+
import asyncio
|
12
|
+
import json
|
13
|
+
|
14
|
+
from falyx.action import Action, ChainedAction
|
15
|
+
from falyx.io_action import ShellAction
|
16
|
+
from falyx.selection_action import SelectionAction
|
17
|
+
|
18
|
+
|
19
|
+
post_ids = ["1", "2", "3", "4", "5"]
|
20
|
+
|
21
|
+
pick_post = SelectionAction(
|
22
|
+
name="Pick Post ID",
|
23
|
+
selections=post_ids,
|
24
|
+
title="Choose a Post ID",
|
25
|
+
prompt_message="Select a post > ",
|
26
|
+
)
|
27
|
+
|
28
|
+
fetch_post = ShellAction(
|
29
|
+
name="Fetch Post via curl",
|
30
|
+
command_template="curl https://jsonplaceholder.typicode.com/posts/{}",
|
31
|
+
)
|
32
|
+
|
33
|
+
async def get_post_title(last_result):
|
34
|
+
return json.loads(last_result).get("title", "No title found")
|
35
|
+
|
36
|
+
post_flow = ChainedAction(
|
37
|
+
name="Fetch and Parse Post",
|
38
|
+
actions=[pick_post, fetch_post, get_post_title],
|
39
|
+
auto_inject=True,
|
40
|
+
)
|
41
|
+
|
42
|
+
async def hello():
|
43
|
+
print("👋 Hello from Falyx!")
|
44
|
+
return "Hello Complete!"
|
45
|
+
|
46
|
+
async def some_work():
|
47
|
+
await asyncio.sleep(2)
|
48
|
+
print("Work Finished!")
|
49
|
+
return "Work Complete!"
|
50
|
+
|
51
|
+
work_action = Action(
|
52
|
+
name="Work Action",
|
53
|
+
action=some_work,
|
54
|
+
)
|
14
55
|
"""
|
15
56
|
|
16
57
|
TEMPLATE_CONFIG = """\
|
17
|
-
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
58
|
+
# falyx.yaml — Config-driven CLI definition
|
59
|
+
# Define your commands here and point to Python callables in tasks.py
|
60
|
+
title: Sample CLI Project
|
61
|
+
prompt:
|
62
|
+
- ["#61AFEF bold", "FALYX > "]
|
63
|
+
columns: 3
|
64
|
+
welcome_message: "🚀 Welcome to your new CLI project!"
|
65
|
+
exit_message: "👋 See you next time!"
|
66
|
+
commands:
|
67
|
+
- key: S
|
68
|
+
description: Say Hello
|
69
|
+
action: tasks.hello
|
70
|
+
aliases: [hi, hello]
|
71
|
+
tags: [example]
|
72
|
+
|
73
|
+
- key: P
|
74
|
+
description: Get Post Title
|
75
|
+
action: tasks.post_flow
|
76
|
+
aliases: [submit]
|
77
|
+
preview_before_confirm: true
|
78
|
+
confirm: true
|
79
|
+
tags: [demo, network]
|
80
|
+
|
81
|
+
- key: G
|
82
|
+
description: Do Some Work
|
83
|
+
action: tasks.work_action
|
84
|
+
aliases: [work]
|
85
|
+
spinner: true
|
86
|
+
spinner_message: "Working..."
|
28
87
|
"""
|
29
88
|
|
30
89
|
GLOBAL_TEMPLATE_TASKS = """\
|
@@ -33,10 +92,12 @@ async def cleanup():
|
|
33
92
|
"""
|
34
93
|
|
35
94
|
GLOBAL_CONFIG = """\
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
95
|
+
title: Global Falyx Config
|
96
|
+
commands:
|
97
|
+
- key: C
|
98
|
+
description: Cleanup temp files
|
99
|
+
action: tasks.cleanup
|
100
|
+
aliases: [clean, cleanup]
|
40
101
|
"""
|
41
102
|
|
42
103
|
console = Console(color_system="auto")
|
@@ -56,7 +117,7 @@ def init_project(name: str = ".") -> None:
|
|
56
117
|
tasks_path.write_text(TEMPLATE_TASKS)
|
57
118
|
config_path.write_text(TEMPLATE_CONFIG)
|
58
119
|
|
59
|
-
print(f"✅ Initialized Falyx project in {target}")
|
120
|
+
console.print(f"✅ Initialized Falyx project in {target}")
|
60
121
|
|
61
122
|
|
62
123
|
def init_global() -> None:
|
falyx/io_action.py
CHANGED
@@ -16,6 +16,7 @@ Common usage includes shell-like filters, input transformers, or any tool that
|
|
16
16
|
needs to consume input from another process or pipeline.
|
17
17
|
"""
|
18
18
|
import asyncio
|
19
|
+
import shlex
|
19
20
|
import subprocess
|
20
21
|
import sys
|
21
22
|
from typing import Any
|
@@ -27,8 +28,8 @@ from falyx.context import ExecutionContext
|
|
27
28
|
from falyx.exceptions import FalyxError
|
28
29
|
from falyx.execution_registry import ExecutionRegistry as er
|
29
30
|
from falyx.hook_manager import HookManager, HookType
|
31
|
+
from falyx.logger import logger
|
30
32
|
from falyx.themes.colors import OneColors
|
31
|
-
from falyx.utils import logger
|
32
33
|
|
33
34
|
|
34
35
|
class BaseIOAction(BaseAction):
|
@@ -77,7 +78,7 @@ class BaseIOAction(BaseAction):
|
|
77
78
|
def from_input(self, raw: str | bytes) -> Any:
|
78
79
|
raise NotImplementedError
|
79
80
|
|
80
|
-
def to_output(self,
|
81
|
+
def to_output(self, result: Any) -> str | bytes:
|
81
82
|
raise NotImplementedError
|
82
83
|
|
83
84
|
async def _resolve_input(self, kwargs: dict[str, Any]) -> str | bytes:
|
@@ -112,7 +113,7 @@ class BaseIOAction(BaseAction):
|
|
112
113
|
try:
|
113
114
|
if self.mode == "stream":
|
114
115
|
line_gen = await self._read_stdin_stream()
|
115
|
-
async for
|
116
|
+
async for _ in self._stream_lines(line_gen, args, kwargs):
|
116
117
|
pass
|
117
118
|
result = getattr(self, "_last_result", None)
|
118
119
|
else:
|
@@ -183,13 +184,14 @@ class ShellAction(BaseIOAction):
|
|
183
184
|
|
184
185
|
Designed for quick integration with shell tools like `grep`, `ping`, `jq`, etc.
|
185
186
|
|
186
|
-
⚠️ Warning:
|
187
|
-
|
188
|
-
|
189
|
-
|
187
|
+
⚠️ Security Warning:
|
188
|
+
By default, ShellAction uses `shell=True`, which can be dangerous with
|
189
|
+
unsanitized input. To mitigate this, set `safe_mode=True` to use `shell=False`
|
190
|
+
with `shlex.split()`.
|
190
191
|
|
191
192
|
Features:
|
192
193
|
- Automatically handles input parsing (str/bytes)
|
194
|
+
- `safe_mode=True` disables shell interpretation and runs with `shell=False`
|
193
195
|
- Captures stdout and stderr from shell execution
|
194
196
|
- Raises on non-zero exit codes with stderr as the error
|
195
197
|
- Result is returned as trimmed stdout string
|
@@ -197,13 +199,19 @@ class ShellAction(BaseIOAction):
|
|
197
199
|
|
198
200
|
Args:
|
199
201
|
name (str): Name of the action.
|
200
|
-
command_template (str): Shell command to execute. Must include `{}` to include
|
201
|
-
If no placeholder is present, the input is not
|
202
|
+
command_template (str): Shell command to execute. Must include `{}` to include
|
203
|
+
input. If no placeholder is present, the input is not
|
204
|
+
included.
|
205
|
+
safe_mode (bool): If True, runs with `shell=False` using shlex parsing
|
206
|
+
(default: False).
|
202
207
|
"""
|
203
208
|
|
204
|
-
def __init__(
|
209
|
+
def __init__(
|
210
|
+
self, name: str, command_template: str, safe_mode: bool = False, **kwargs
|
211
|
+
):
|
205
212
|
super().__init__(name=name, **kwargs)
|
206
213
|
self.command_template = command_template
|
214
|
+
self.safe_mode = safe_mode
|
207
215
|
|
208
216
|
def from_input(self, raw: str | bytes) -> str:
|
209
217
|
if not isinstance(raw, (str, bytes)):
|
@@ -215,7 +223,13 @@ class ShellAction(BaseIOAction):
|
|
215
223
|
async def _run(self, parsed_input: str) -> str:
|
216
224
|
# Replace placeholder in template, or use raw input as full command
|
217
225
|
command = self.command_template.format(parsed_input)
|
218
|
-
|
226
|
+
if self.safe_mode:
|
227
|
+
args = shlex.split(command)
|
228
|
+
result = subprocess.run(args, capture_output=True, text=True, check=True)
|
229
|
+
else:
|
230
|
+
result = subprocess.run(
|
231
|
+
command, shell=True, text=True, capture_output=True, check=True
|
232
|
+
)
|
219
233
|
if result.returncode != 0:
|
220
234
|
raise RuntimeError(result.stderr.strip())
|
221
235
|
return result.stdout.strip()
|
@@ -225,14 +239,18 @@ class ShellAction(BaseIOAction):
|
|
225
239
|
|
226
240
|
async def preview(self, parent: Tree | None = None):
|
227
241
|
label = [f"[{OneColors.GREEN_b}]⚙ ShellAction[/] '{self.name}'"]
|
242
|
+
label.append(f"\n[dim]Template:[/] {self.command_template}")
|
243
|
+
label.append(
|
244
|
+
f"\n[dim]Safe mode:[/] {'Enabled' if self.safe_mode else 'Disabled'}"
|
245
|
+
)
|
228
246
|
if self.inject_last_result:
|
229
247
|
label.append(f" [dim](injects '{self.inject_into}')[/dim]")
|
230
|
-
if parent
|
231
|
-
|
232
|
-
|
233
|
-
self.console.print(Tree("".join(label)))
|
248
|
+
tree = parent.add("".join(label)) if parent else Tree("".join(label))
|
249
|
+
if not parent:
|
250
|
+
self.console.print(tree)
|
234
251
|
|
235
252
|
def __str__(self):
|
236
253
|
return (
|
237
|
-
f"ShellAction(name={self.name!r}, command_template={self.command_template!r}
|
254
|
+
f"ShellAction(name={self.name!r}, command_template={self.command_template!r},"
|
255
|
+
f" safe_mode={self.safe_mode})"
|
238
256
|
)
|
falyx/logger.py
ADDED
falyx/menu_action.py
CHANGED
@@ -12,15 +12,18 @@ from falyx.action import BaseAction
|
|
12
12
|
from falyx.context import ExecutionContext
|
13
13
|
from falyx.execution_registry import ExecutionRegistry as er
|
14
14
|
from falyx.hook_manager import HookType
|
15
|
+
from falyx.logger import logger
|
15
16
|
from falyx.selection import prompt_for_selection, render_table_base
|
16
17
|
from falyx.signal_action import SignalAction
|
17
18
|
from falyx.signals import BackSignal, QuitSignal
|
18
19
|
from falyx.themes.colors import OneColors
|
19
|
-
from falyx.utils import CaseInsensitiveDict, chunks
|
20
|
+
from falyx.utils import CaseInsensitiveDict, chunks
|
20
21
|
|
21
22
|
|
22
23
|
@dataclass
|
23
24
|
class MenuOption:
|
25
|
+
"""Represents a single menu option with a description and an action to execute."""
|
26
|
+
|
24
27
|
description: str
|
25
28
|
action: BaseAction
|
26
29
|
style: str = OneColors.WHITE
|
@@ -93,6 +96,8 @@ class MenuOptionMap(CaseInsensitiveDict):
|
|
93
96
|
|
94
97
|
|
95
98
|
class MenuAction(BaseAction):
|
99
|
+
"""MenuAction class for creating single use menu actions."""
|
100
|
+
|
96
101
|
def __init__(
|
97
102
|
self,
|
98
103
|
name: str,
|
@@ -162,7 +167,8 @@ class MenuAction(BaseAction):
|
|
162
167
|
|
163
168
|
if self.never_prompt and not effective_default:
|
164
169
|
raise ValueError(
|
165
|
-
f"[{self.name}] 'never_prompt' is True but no valid default_selection
|
170
|
+
f"[{self.name}] 'never_prompt' is True but no valid default_selection"
|
171
|
+
" was provided."
|
166
172
|
)
|
167
173
|
|
168
174
|
context.start_timer()
|
falyx/options_manager.py
CHANGED
@@ -5,12 +5,14 @@ from argparse import Namespace
|
|
5
5
|
from collections import defaultdict
|
6
6
|
from typing import Any, Callable
|
7
7
|
|
8
|
-
from falyx.
|
8
|
+
from falyx.logger import logger
|
9
9
|
|
10
10
|
|
11
11
|
class OptionsManager:
|
12
|
+
"""OptionsManager"""
|
13
|
+
|
12
14
|
def __init__(self, namespaces: list[tuple[str, Namespace]] | None = None) -> None:
|
13
|
-
self.options: defaultdict = defaultdict(
|
15
|
+
self.options: defaultdict = defaultdict(Namespace)
|
14
16
|
if namespaces:
|
15
17
|
for namespace_name, namespace in namespaces:
|
16
18
|
self.from_namespace(namespace, namespace_name)
|
@@ -42,7 +44,9 @@ class OptionsManager:
|
|
42
44
|
f"Cannot toggle non-boolean option: '{option_name}' in '{namespace_name}'"
|
43
45
|
)
|
44
46
|
self.set(option_name, not current, namespace_name=namespace_name)
|
45
|
-
logger.debug(
|
47
|
+
logger.debug(
|
48
|
+
"Toggled '%s' in '%s' to %s", option_name, namespace_name, not current
|
49
|
+
)
|
46
50
|
|
47
51
|
def get_value_getter(
|
48
52
|
self, option_name: str, namespace_name: str = "cli_args"
|
falyx/parsers.py
CHANGED
@@ -39,7 +39,7 @@ def get_arg_parsers(
|
|
39
39
|
epilog: (
|
40
40
|
str | None
|
41
41
|
) = "Tip: Use 'falyx run ?[COMMAND]' to preview any command from the CLI.",
|
42
|
-
parents: Sequence[ArgumentParser] =
|
42
|
+
parents: Sequence[ArgumentParser] | None = None,
|
43
43
|
prefix_chars: str = "-",
|
44
44
|
fromfile_prefix_chars: str | None = None,
|
45
45
|
argument_default: Any = None,
|
@@ -54,7 +54,7 @@ def get_arg_parsers(
|
|
54
54
|
usage=usage,
|
55
55
|
description=description,
|
56
56
|
epilog=epilog,
|
57
|
-
parents=parents,
|
57
|
+
parents=parents if parents else [],
|
58
58
|
prefix_chars=prefix_chars,
|
59
59
|
fromfile_prefix_chars=fromfile_prefix_chars,
|
60
60
|
argument_default=argument_default,
|
falyx/prompt_utils.py
CHANGED
@@ -1,4 +1,15 @@
|
|
1
|
+
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
2
|
+
"""prompt_utils.py"""
|
3
|
+
from prompt_toolkit import PromptSession
|
4
|
+
from prompt_toolkit.formatted_text import (
|
5
|
+
AnyFormattedText,
|
6
|
+
FormattedText,
|
7
|
+
merge_formatted_text,
|
8
|
+
)
|
9
|
+
|
1
10
|
from falyx.options_manager import OptionsManager
|
11
|
+
from falyx.themes.colors import OneColors
|
12
|
+
from falyx.validators import yes_no_validator
|
2
13
|
|
3
14
|
|
4
15
|
def should_prompt_user(
|
@@ -7,7 +18,10 @@ def should_prompt_user(
|
|
7
18
|
options: OptionsManager,
|
8
19
|
namespace: str = "cli_args",
|
9
20
|
):
|
10
|
-
"""
|
21
|
+
"""
|
22
|
+
Determine whether to prompt the user for confirmation based on command
|
23
|
+
and global options.
|
24
|
+
"""
|
11
25
|
never_prompt = options.get("never_prompt", False, namespace)
|
12
26
|
force_confirm = options.get("force_confirm", False, namespace)
|
13
27
|
skip_confirm = options.get("skip_confirm", False, namespace)
|
@@ -16,3 +30,19 @@ def should_prompt_user(
|
|
16
30
|
return False
|
17
31
|
|
18
32
|
return confirm or force_confirm
|
33
|
+
|
34
|
+
|
35
|
+
async def confirm_async(
|
36
|
+
message: AnyFormattedText = "Are you sure?",
|
37
|
+
prefix: AnyFormattedText = FormattedText([(OneColors.CYAN, "❓ ")]),
|
38
|
+
suffix: AnyFormattedText = FormattedText([(OneColors.LIGHT_YELLOW_b, " [Y/n] > ")]),
|
39
|
+
session: PromptSession | None = None,
|
40
|
+
) -> bool:
|
41
|
+
"""Prompt the user with a yes/no async confirmation and return True for 'Y'."""
|
42
|
+
session = session or PromptSession()
|
43
|
+
merged_message: AnyFormattedText = merge_formatted_text([prefix, message, suffix])
|
44
|
+
answer = await session.prompt_async(
|
45
|
+
merged_message,
|
46
|
+
validator=yes_no_validator(),
|
47
|
+
)
|
48
|
+
return answer.upper() == "Y"
|
falyx/protocols.py
CHANGED
falyx/retry.py
CHANGED
@@ -8,10 +8,12 @@ import random
|
|
8
8
|
from pydantic import BaseModel, Field
|
9
9
|
|
10
10
|
from falyx.context import ExecutionContext
|
11
|
-
from falyx.
|
11
|
+
from falyx.logger import logger
|
12
12
|
|
13
13
|
|
14
14
|
class RetryPolicy(BaseModel):
|
15
|
+
"""RetryPolicy"""
|
16
|
+
|
15
17
|
max_retries: int = Field(default=3, ge=0)
|
16
18
|
delay: float = Field(default=1.0, ge=0.0)
|
17
19
|
backoff: float = Field(default=2.0, ge=1.0)
|
@@ -34,6 +36,8 @@ class RetryPolicy(BaseModel):
|
|
34
36
|
|
35
37
|
|
36
38
|
class RetryHandler:
|
39
|
+
"""RetryHandler class to manage retry policies for actions."""
|
40
|
+
|
37
41
|
def __init__(self, policy: RetryPolicy = RetryPolicy()):
|
38
42
|
self.policy = policy
|
39
43
|
|
@@ -49,7 +53,7 @@ class RetryHandler:
|
|
49
53
|
self.policy.delay = delay
|
50
54
|
self.policy.backoff = backoff
|
51
55
|
self.policy.jitter = jitter
|
52
|
-
logger.info(
|
56
|
+
logger.info("🔄 Retry policy enabled: %s", self.policy)
|
53
57
|
|
54
58
|
async def retry_on_error(self, context: ExecutionContext) -> None:
|
55
59
|
from falyx.action import Action
|
@@ -63,21 +67,21 @@ class RetryHandler:
|
|
63
67
|
last_error = error
|
64
68
|
|
65
69
|
if not target:
|
66
|
-
logger.warning(
|
70
|
+
logger.warning("[%s] ⚠️ No action target. Cannot retry.", name)
|
67
71
|
return None
|
68
72
|
|
69
73
|
if not isinstance(target, Action):
|
70
74
|
logger.warning(
|
71
|
-
|
75
|
+
"[%s] ❌ RetryHandler only supports only supports Action objects.", name
|
72
76
|
)
|
73
77
|
return None
|
74
78
|
|
75
79
|
if not getattr(target, "is_retryable", False):
|
76
|
-
logger.warning(
|
80
|
+
logger.warning("[%s] ❌ Not retryable.", name)
|
77
81
|
return None
|
78
82
|
|
79
83
|
if not self.policy.enabled:
|
80
|
-
logger.warning(
|
84
|
+
logger.warning("[%s] ❌ Retry policy is disabled.", name)
|
81
85
|
return None
|
82
86
|
|
83
87
|
while retries_done < self.policy.max_retries:
|
@@ -88,23 +92,30 @@ class RetryHandler:
|
|
88
92
|
sleep_delay += random.uniform(-self.policy.jitter, self.policy.jitter)
|
89
93
|
|
90
94
|
logger.info(
|
91
|
-
|
92
|
-
|
95
|
+
"[%s] 🔄 Retrying (%s/%s) in %ss due to '%s'...",
|
96
|
+
name,
|
97
|
+
retries_done,
|
98
|
+
self.policy.max_retries,
|
99
|
+
current_delay,
|
100
|
+
last_error,
|
93
101
|
)
|
94
102
|
await asyncio.sleep(current_delay)
|
95
103
|
try:
|
96
104
|
result = await target.action(*context.args, **context.kwargs)
|
97
105
|
context.result = result
|
98
106
|
context.exception = None
|
99
|
-
logger.info(
|
107
|
+
logger.info("[%s] ✅ Retry succeeded on attempt %s.", name, retries_done)
|
100
108
|
return None
|
101
109
|
except Exception as retry_error:
|
102
110
|
last_error = retry_error
|
103
111
|
current_delay *= self.policy.backoff
|
104
112
|
logger.warning(
|
105
|
-
|
106
|
-
|
113
|
+
"[%s] ⚠️ Retry attempt %s/%s failed due to '%s'.",
|
114
|
+
name,
|
115
|
+
retries_done,
|
116
|
+
self.policy.max_retries,
|
117
|
+
retry_error,
|
107
118
|
)
|
108
119
|
|
109
120
|
context.exception = last_error
|
110
|
-
logger.error(
|
121
|
+
logger.error("[%s] ❌ All %s retries failed.", name, self.policy.max_retries)
|
falyx/retry_utils.py
CHANGED
falyx/select_file_action.py
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
2
|
+
"""select_file_action.py"""
|
1
3
|
from __future__ import annotations
|
2
4
|
|
3
5
|
import csv
|
@@ -17,24 +19,48 @@ from falyx.action import BaseAction
|
|
17
19
|
from falyx.context import ExecutionContext
|
18
20
|
from falyx.execution_registry import ExecutionRegistry as er
|
19
21
|
from falyx.hook_manager import HookType
|
22
|
+
from falyx.logger import logger
|
20
23
|
from falyx.selection import (
|
21
24
|
SelectionOption,
|
22
25
|
prompt_for_selection,
|
23
26
|
render_selection_dict_table,
|
24
27
|
)
|
25
28
|
from falyx.themes.colors import OneColors
|
26
|
-
from falyx.utils import logger
|
27
29
|
|
28
30
|
|
29
31
|
class FileReturnType(Enum):
|
32
|
+
"""Enum for file return types."""
|
33
|
+
|
30
34
|
TEXT = "text"
|
31
35
|
PATH = "path"
|
32
36
|
JSON = "json"
|
33
37
|
TOML = "toml"
|
34
38
|
YAML = "yaml"
|
35
39
|
CSV = "csv"
|
40
|
+
TSV = "tsv"
|
36
41
|
XML = "xml"
|
37
42
|
|
43
|
+
@classmethod
|
44
|
+
def _get_alias(cls, value: str) -> str:
|
45
|
+
aliases = {
|
46
|
+
"yml": "yaml",
|
47
|
+
"txt": "text",
|
48
|
+
"file": "path",
|
49
|
+
"filepath": "path",
|
50
|
+
}
|
51
|
+
return aliases.get(value, value)
|
52
|
+
|
53
|
+
@classmethod
|
54
|
+
def _missing_(cls, value: object) -> FileReturnType:
|
55
|
+
if isinstance(value, str):
|
56
|
+
normalized = value.lower()
|
57
|
+
alias = cls._get_alias(normalized)
|
58
|
+
for member in cls:
|
59
|
+
if member.value == alias:
|
60
|
+
return member
|
61
|
+
valid = ", ".join(member.value for member in cls)
|
62
|
+
raise ValueError(f"Invalid FileReturnType: '{value}'. Must be one of: {valid}")
|
63
|
+
|
38
64
|
|
39
65
|
class SelectFileAction(BaseAction):
|
40
66
|
"""
|
@@ -42,7 +68,7 @@ class SelectFileAction(BaseAction):
|
|
42
68
|
- file content (as text, JSON, CSV, etc.)
|
43
69
|
- or the file path itself.
|
44
70
|
|
45
|
-
Supported formats: text, json, yaml, toml, csv, xml.
|
71
|
+
Supported formats: text, json, yaml, toml, csv, tsv, xml.
|
46
72
|
|
47
73
|
Useful for:
|
48
74
|
- dynamically loading config files
|
@@ -72,7 +98,7 @@ class SelectFileAction(BaseAction):
|
|
72
98
|
prompt_message: str = "Choose > ",
|
73
99
|
style: str = OneColors.WHITE,
|
74
100
|
suffix_filter: str | None = None,
|
75
|
-
return_type: FileReturnType = FileReturnType.PATH,
|
101
|
+
return_type: FileReturnType | str = FileReturnType.PATH,
|
76
102
|
console: Console | None = None,
|
77
103
|
prompt_session: PromptSession | None = None,
|
78
104
|
):
|
@@ -83,9 +109,14 @@ class SelectFileAction(BaseAction):
|
|
83
109
|
self.prompt_message = prompt_message
|
84
110
|
self.suffix_filter = suffix_filter
|
85
111
|
self.style = style
|
86
|
-
self.return_type = return_type
|
87
112
|
self.console = console or Console(color_system="auto")
|
88
113
|
self.prompt_session = prompt_session or PromptSession()
|
114
|
+
self.return_type = self._coerce_return_type(return_type)
|
115
|
+
|
116
|
+
def _coerce_return_type(self, return_type: FileReturnType | str) -> FileReturnType:
|
117
|
+
if isinstance(return_type, FileReturnType):
|
118
|
+
return return_type
|
119
|
+
return FileReturnType(return_type)
|
89
120
|
|
90
121
|
def get_options(self, files: list[Path]) -> dict[str, SelectionOption]:
|
91
122
|
value: Any
|
@@ -106,6 +137,10 @@ class SelectFileAction(BaseAction):
|
|
106
137
|
with open(file, newline="", encoding="UTF-8") as csvfile:
|
107
138
|
reader = csv.reader(csvfile)
|
108
139
|
value = list(reader)
|
140
|
+
elif self.return_type == FileReturnType.TSV:
|
141
|
+
with open(file, newline="", encoding="UTF-8") as tsvfile:
|
142
|
+
reader = csv.reader(tsvfile, delimiter="\t")
|
143
|
+
value = list(reader)
|
109
144
|
elif self.return_type == FileReturnType.XML:
|
110
145
|
tree = ET.parse(file, parser=ET.XMLParser(encoding="UTF-8"))
|
111
146
|
root = tree.getroot()
|
@@ -183,7 +218,7 @@ class SelectFileAction(BaseAction):
|
|
183
218
|
if len(files) > 10:
|
184
219
|
file_list.add(f"[dim]... ({len(files) - 10} more)[/]")
|
185
220
|
except Exception as error:
|
186
|
-
tree.add(f"[
|
221
|
+
tree.add(f"[{OneColors.DARK_RED_b}]⚠️ Error scanning directory: {error}[/]")
|
187
222
|
|
188
223
|
if not parent:
|
189
224
|
self.console.print(tree)
|
falyx/selection.py
CHANGED
@@ -16,6 +16,8 @@ from falyx.validators import int_range_validator, key_validator
|
|
16
16
|
|
17
17
|
@dataclass
|
18
18
|
class SelectionOption:
|
19
|
+
"""Represents a single selection option with a description and a value."""
|
20
|
+
|
19
21
|
description: str
|
20
22
|
value: Any
|
21
23
|
style: str = OneColors.WHITE
|
@@ -26,7 +28,8 @@ class SelectionOption:
|
|
26
28
|
|
27
29
|
def render(self, key: str) -> str:
|
28
30
|
"""Render the selection option for display."""
|
29
|
-
|
31
|
+
key = escape(f"[{key}]")
|
32
|
+
return f"[{OneColors.WHITE}]{key}[/] [{self.style}]{self.description}[/]"
|
30
33
|
|
31
34
|
|
32
35
|
def render_table_base(
|
@@ -194,7 +197,8 @@ def render_selection_dict_table(
|
|
194
197
|
row = []
|
195
198
|
for key, option in chunk:
|
196
199
|
row.append(
|
197
|
-
f"[{OneColors.WHITE}][{key.upper()}]
|
200
|
+
f"[{OneColors.WHITE}][{key.upper()}] "
|
201
|
+
f"[{option.style}]{option.description}[/]"
|
198
202
|
)
|
199
203
|
table.add_row(*row)
|
200
204
|
|
@@ -216,7 +220,7 @@ async def prompt_for_index(
|
|
216
220
|
console = console or Console(color_system="auto")
|
217
221
|
|
218
222
|
if show_table:
|
219
|
-
console.print(table)
|
223
|
+
console.print(table, justify="center")
|
220
224
|
|
221
225
|
selection = await prompt_session.prompt_async(
|
222
226
|
message=prompt_message,
|
@@ -318,7 +322,7 @@ async def select_key_from_dict(
|
|
318
322
|
prompt_session = prompt_session or PromptSession()
|
319
323
|
console = console or Console(color_system="auto")
|
320
324
|
|
321
|
-
console.print(table)
|
325
|
+
console.print(table, justify="center")
|
322
326
|
|
323
327
|
return await prompt_for_selection(
|
324
328
|
selections.keys(),
|
@@ -343,7 +347,7 @@ async def select_value_from_dict(
|
|
343
347
|
prompt_session = prompt_session or PromptSession()
|
344
348
|
console = console or Console(color_system="auto")
|
345
349
|
|
346
|
-
console.print(table)
|
350
|
+
console.print(table, justify="center")
|
347
351
|
|
348
352
|
selection_key = await prompt_for_selection(
|
349
353
|
selections.keys(),
|