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/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
- async def build():
8
- print("🔨 Building project...")
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
- async def test():
12
- print("🧪 Running tests...")
13
- return "Tests complete!"
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
- - key: B
18
- description: Build the project
19
- action: tasks.build
20
- aliases: [build]
21
- spinner: true
22
-
23
- - key: T
24
- description: Run tests
25
- action: tasks.test
26
- aliases: [test]
27
- spinner: true
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
- - key: C
37
- description: Cleanup temp files
38
- action: tasks.cleanup
39
- aliases: [clean, cleanup]
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, data: Any) -> str | bytes:
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 line in self._stream_lines(line_gen, args, kwargs):
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
- Be cautious when using ShellAction with untrusted user input. Since it uses
188
- `shell=True`, unsanitized input can lead to command injection vulnerabilities.
189
- Avoid passing raw user input directly unless the template or use case is secure.
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 input.
201
- If no placeholder is present, the input is not included.
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__(self, name: str, command_template: str, **kwargs):
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
- result = subprocess.run(command, shell=True, text=True, capture_output=True)
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
- parent.add("".join(label))
232
- else:
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
@@ -0,0 +1,5 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
+ """logger.py"""
3
+ import logging
4
+
5
+ logger = logging.getLogger("falyx")
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, logger
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 was provided."
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.utils import logger
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(lambda: Namespace())
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(f"Toggled '{option_name}' in '{namespace_name}' to {not current}")
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
- """Determine whether to prompt the user for confirmation based on command and global options."""
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
@@ -1,3 +1,5 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
+ """protocols.py"""
1
3
  from __future__ import annotations
2
4
 
3
5
  from typing import Any, Protocol
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.utils import logger
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(f"🔄 Retry policy enabled: {self.policy}")
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(f"[{name}] ⚠️ No action target. Cannot retry.")
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
- f"[{name}] ❌ RetryHandler only supports only supports Action objects."
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(f"[{name}] ❌ Not retryable.")
80
+ logger.warning("[%s] ❌ Not retryable.", name)
77
81
  return None
78
82
 
79
83
  if not self.policy.enabled:
80
- logger.warning(f"[{name}] ❌ Retry policy is disabled.")
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
- f"[{name}] 🔄 Retrying ({retries_done}/{self.policy.max_retries}) "
92
- f"in {current_delay}s due to '{last_error}'..."
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(f"[{name}] ✅ Retry succeeded on attempt {retries_done}.")
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
- f"[{name}] ⚠️ Retry attempt {retries_done}/{self.policy.max_retries} "
106
- f"failed due to '{retry_error}'."
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(f"[{name}] ❌ All {self.policy.max_retries} retries failed.")
121
+ logger.error("[%s] ❌ All %s retries failed.", name, self.policy.max_retries)
falyx/retry_utils.py CHANGED
@@ -1,4 +1,5 @@
1
1
  # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
+ """retry_utils.py"""
2
3
  from falyx.action import Action, BaseAction
3
4
  from falyx.hook_manager import HookType
4
5
  from falyx.retry import RetryHandler, RetryPolicy
@@ -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"[bold red]⚠️ Error scanning directory: {error}[/]")
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
- return f"[{OneColors.WHITE}][{key}][/] [{self.style}]{self.description}[/]"
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()}] [{option.style}]{option.description}[/]"
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(),