falyx 0.1.53__tar.gz → 0.1.54__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 (67) hide show
  1. {falyx-0.1.53 → falyx-0.1.54}/PKG-INFO +1 -1
  2. {falyx-0.1.53 → falyx-0.1.54}/falyx/action/__init__.py +4 -2
  3. {falyx-0.1.53 → falyx-0.1.54}/falyx/action/action.py +7 -7
  4. {falyx-0.1.53 → falyx-0.1.54}/falyx/action/action_factory.py +2 -2
  5. {falyx-0.1.53 → falyx-0.1.54}/falyx/action/action_group.py +8 -3
  6. falyx-0.1.53/falyx/action/mixins.py → falyx-0.1.54/falyx/action/action_mixins.py +1 -1
  7. {falyx-0.1.53 → falyx-0.1.54}/falyx/action/base_action.py +0 -1
  8. {falyx-0.1.53 → falyx-0.1.54}/falyx/action/chained_action.py +6 -3
  9. falyx-0.1.54/falyx/action/load_file_action.py +196 -0
  10. {falyx-0.1.53 → falyx-0.1.54}/falyx/action/process_pool_action.py +6 -3
  11. {falyx-0.1.53 → falyx-0.1.54}/falyx/action/save_file_action.py +17 -1
  12. {falyx-0.1.53 → falyx-0.1.54}/falyx/action/select_file_action.py +4 -1
  13. {falyx-0.1.53 → falyx-0.1.54}/falyx/command.py +2 -2
  14. {falyx-0.1.53 → falyx-0.1.54}/falyx/exceptions.py +8 -0
  15. {falyx-0.1.53 → falyx-0.1.54}/falyx/logger.py +1 -1
  16. {falyx-0.1.53 → falyx-0.1.54}/falyx/parser/argument.py +2 -0
  17. {falyx-0.1.53 → falyx-0.1.54}/falyx/parser/command_argument_parser.py +170 -152
  18. {falyx-0.1.53 → falyx-0.1.54}/falyx/parser/utils.py +0 -1
  19. {falyx-0.1.53 → falyx-0.1.54}/falyx/protocols.py +4 -2
  20. falyx-0.1.54/falyx/version.py +1 -0
  21. {falyx-0.1.53 → falyx-0.1.54}/pyproject.toml +5 -1
  22. falyx-0.1.53/falyx/action/load_file_action.py +0 -28
  23. falyx-0.1.53/falyx/version.py +0 -1
  24. {falyx-0.1.53 → falyx-0.1.54}/LICENSE +0 -0
  25. {falyx-0.1.53 → falyx-0.1.54}/README.md +0 -0
  26. {falyx-0.1.53 → falyx-0.1.54}/falyx/.pytyped +0 -0
  27. {falyx-0.1.53 → falyx-0.1.54}/falyx/__init__.py +0 -0
  28. {falyx-0.1.53 → falyx-0.1.54}/falyx/__main__.py +0 -0
  29. {falyx-0.1.53 → falyx-0.1.54}/falyx/action/.pytyped +0 -0
  30. {falyx-0.1.53 → falyx-0.1.54}/falyx/action/action_types.py +0 -0
  31. {falyx-0.1.53 → falyx-0.1.54}/falyx/action/fallback_action.py +0 -0
  32. {falyx-0.1.53 → falyx-0.1.54}/falyx/action/http_action.py +0 -0
  33. {falyx-0.1.53 → falyx-0.1.54}/falyx/action/io_action.py +0 -0
  34. {falyx-0.1.53 → falyx-0.1.54}/falyx/action/literal_input_action.py +0 -0
  35. {falyx-0.1.53 → falyx-0.1.54}/falyx/action/menu_action.py +0 -0
  36. {falyx-0.1.53 → falyx-0.1.54}/falyx/action/process_action.py +0 -0
  37. {falyx-0.1.53 → falyx-0.1.54}/falyx/action/prompt_menu_action.py +0 -0
  38. {falyx-0.1.53 → falyx-0.1.54}/falyx/action/selection_action.py +0 -0
  39. {falyx-0.1.53 → falyx-0.1.54}/falyx/action/shell_action.py +0 -0
  40. {falyx-0.1.53 → falyx-0.1.54}/falyx/action/signal_action.py +0 -0
  41. {falyx-0.1.53 → falyx-0.1.54}/falyx/action/user_input_action.py +0 -0
  42. {falyx-0.1.53 → falyx-0.1.54}/falyx/bottom_bar.py +0 -0
  43. {falyx-0.1.53 → falyx-0.1.54}/falyx/config.py +0 -0
  44. {falyx-0.1.53 → falyx-0.1.54}/falyx/context.py +0 -0
  45. {falyx-0.1.53 → falyx-0.1.54}/falyx/debug.py +0 -0
  46. {falyx-0.1.53 → falyx-0.1.54}/falyx/execution_registry.py +0 -0
  47. {falyx-0.1.53 → falyx-0.1.54}/falyx/falyx.py +0 -0
  48. {falyx-0.1.53 → falyx-0.1.54}/falyx/hook_manager.py +0 -0
  49. {falyx-0.1.53 → falyx-0.1.54}/falyx/hooks.py +0 -0
  50. {falyx-0.1.53 → falyx-0.1.54}/falyx/init.py +0 -0
  51. {falyx-0.1.53 → falyx-0.1.54}/falyx/menu.py +0 -0
  52. {falyx-0.1.53 → falyx-0.1.54}/falyx/options_manager.py +0 -0
  53. {falyx-0.1.53 → falyx-0.1.54}/falyx/parser/.pytyped +0 -0
  54. {falyx-0.1.53 → falyx-0.1.54}/falyx/parser/__init__.py +0 -0
  55. {falyx-0.1.53 → falyx-0.1.54}/falyx/parser/argument_action.py +0 -0
  56. {falyx-0.1.53 → falyx-0.1.54}/falyx/parser/parsers.py +0 -0
  57. {falyx-0.1.53 → falyx-0.1.54}/falyx/parser/signature.py +0 -0
  58. {falyx-0.1.53 → falyx-0.1.54}/falyx/prompt_utils.py +0 -0
  59. {falyx-0.1.53 → falyx-0.1.54}/falyx/retry.py +0 -0
  60. {falyx-0.1.53 → falyx-0.1.54}/falyx/retry_utils.py +0 -0
  61. {falyx-0.1.53 → falyx-0.1.54}/falyx/selection.py +0 -0
  62. {falyx-0.1.53 → falyx-0.1.54}/falyx/signals.py +0 -0
  63. {falyx-0.1.53 → falyx-0.1.54}/falyx/tagged_table.py +0 -0
  64. {falyx-0.1.53 → falyx-0.1.54}/falyx/themes/__init__.py +0 -0
  65. {falyx-0.1.53 → falyx-0.1.54}/falyx/themes/colors.py +0 -0
  66. {falyx-0.1.53 → falyx-0.1.54}/falyx/utils.py +0 -0
  67. {falyx-0.1.53 → falyx-0.1.54}/falyx/validators.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: falyx
3
- Version: 0.1.53
3
+ Version: 0.1.54
4
4
  Summary: Reliable and introspectable async CLI action framework.
5
5
  License: MIT
6
6
  Author: Roland Thomas Jr
@@ -6,7 +6,7 @@ Licensed under the MIT License. See LICENSE file for details.
6
6
  """
7
7
 
8
8
  from .action import Action
9
- from .action_factory import ActionFactoryAction
9
+ from .action_factory import ActionFactory
10
10
  from .action_group import ActionGroup
11
11
  from .base_action import BaseAction
12
12
  from .chained_action import ChainedAction
@@ -14,6 +14,7 @@ from .fallback_action import FallbackAction
14
14
  from .http_action import HTTPAction
15
15
  from .io_action import BaseIOAction
16
16
  from .literal_input_action import LiteralInputAction
17
+ from .load_file_action import LoadFileAction
17
18
  from .menu_action import MenuAction
18
19
  from .process_action import ProcessAction
19
20
  from .process_pool_action import ProcessPoolAction
@@ -30,7 +31,7 @@ __all__ = [
30
31
  "BaseAction",
31
32
  "ChainedAction",
32
33
  "ProcessAction",
33
- "ActionFactoryAction",
34
+ "ActionFactory",
34
35
  "HTTPAction",
35
36
  "BaseIOAction",
36
37
  "ShellAction",
@@ -43,4 +44,5 @@ __all__ = [
43
44
  "UserInputAction",
44
45
  "PromptMenuAction",
45
46
  "ProcessPoolAction",
47
+ "LoadFileAction",
46
48
  ]
@@ -2,7 +2,7 @@
2
2
  """action.py"""
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Any, Callable
5
+ from typing import Any, Awaitable, Callable
6
6
 
7
7
  from rich.tree import Tree
8
8
 
@@ -42,9 +42,9 @@ class Action(BaseAction):
42
42
  def __init__(
43
43
  self,
44
44
  name: str,
45
- action: Callable[..., Any],
45
+ action: Callable[..., Any] | Callable[..., Awaitable[Any]],
46
46
  *,
47
- rollback: Callable[..., Any] | None = None,
47
+ rollback: Callable[..., Any] | Callable[..., Awaitable[Any]] | None = None,
48
48
  args: tuple[Any, ...] = (),
49
49
  kwargs: dict[str, Any] | None = None,
50
50
  hooks: HookManager | None = None,
@@ -69,19 +69,19 @@ class Action(BaseAction):
69
69
  self.enable_retry()
70
70
 
71
71
  @property
72
- def action(self) -> Callable[..., Any]:
72
+ def action(self) -> Callable[..., Awaitable[Any]]:
73
73
  return self._action
74
74
 
75
75
  @action.setter
76
- def action(self, value: Callable[..., Any]):
76
+ def action(self, value: Callable[..., Awaitable[Any]]):
77
77
  self._action = ensure_async(value)
78
78
 
79
79
  @property
80
- def rollback(self) -> Callable[..., Any] | None:
80
+ def rollback(self) -> Callable[..., Awaitable[Any]] | None:
81
81
  return self._rollback
82
82
 
83
83
  @rollback.setter
84
- def rollback(self, value: Callable[..., Any] | None):
84
+ def rollback(self, value: Callable[..., Awaitable[Any]] | None):
85
85
  if value is None:
86
86
  self._rollback = None
87
87
  else:
@@ -1,5 +1,5 @@
1
1
  # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
- """action_factory.py"""
2
+ """action_factory_action.py"""
3
3
  from typing import Any, Callable
4
4
 
5
5
  from rich.tree import Tree
@@ -14,7 +14,7 @@ from falyx.themes import OneColors
14
14
  from falyx.utils import ensure_async
15
15
 
16
16
 
17
- class ActionFactoryAction(BaseAction):
17
+ class ActionFactory(BaseAction):
18
18
  """
19
19
  Dynamically creates and runs another Action at runtime using a factory function.
20
20
 
@@ -2,14 +2,15 @@
2
2
  """action_group.py"""
3
3
  import asyncio
4
4
  import random
5
- from typing import Any, Callable, Sequence
5
+ from typing import Any, Awaitable, Callable, Sequence
6
6
 
7
7
  from rich.tree import Tree
8
8
 
9
9
  from falyx.action.action import Action
10
+ from falyx.action.action_mixins import ActionListMixin
10
11
  from falyx.action.base_action import BaseAction
11
- from falyx.action.mixins import ActionListMixin
12
12
  from falyx.context import ExecutionContext, SharedContext
13
+ from falyx.exceptions import EmptyGroupError
13
14
  from falyx.execution_registry import ExecutionRegistry as er
14
15
  from falyx.hook_manager import Hook, HookManager, HookType
15
16
  from falyx.logger import logger
@@ -54,7 +55,9 @@ class ActionGroup(BaseAction, ActionListMixin):
54
55
  def __init__(
55
56
  self,
56
57
  name: str,
57
- actions: Sequence[BaseAction | Callable[..., Any]] | None = None,
58
+ actions: (
59
+ Sequence[BaseAction | Callable[..., Any] | Callable[..., Awaitable]] | None
60
+ ) = None,
58
61
  *,
59
62
  hooks: HookManager | None = None,
60
63
  inject_last_result: bool = False,
@@ -104,6 +107,8 @@ class ActionGroup(BaseAction, ActionListMixin):
104
107
  return None, None
105
108
 
106
109
  async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]:
110
+ if not self.actions:
111
+ raise EmptyGroupError(f"[{self.name}] No actions to execute.")
107
112
  shared_context = SharedContext(name=self.name, action=self, is_parallel=True)
108
113
  if self.shared_context:
109
114
  shared_context.set_shared_result(self.shared_context.last_result())
@@ -1,5 +1,5 @@
1
1
  # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
- """mixins.py"""
2
+ """action_mixins.py"""
3
3
  from typing import Sequence
4
4
 
5
5
  from falyx.action.base_action import BaseAction
@@ -38,7 +38,6 @@ from rich.tree import Tree
38
38
 
39
39
  from falyx.context import SharedContext
40
40
  from falyx.debug import register_debug_hooks
41
- from falyx.execution_registry import ExecutionRegistry as er
42
41
  from falyx.hook_manager import Hook, HookManager, HookType
43
42
  from falyx.logger import logger
44
43
  from falyx.options_manager import OptionsManager
@@ -2,15 +2,15 @@
2
2
  """chained_action.py"""
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Any, Callable, Sequence
5
+ from typing import Any, Awaitable, Callable, Sequence
6
6
 
7
7
  from rich.tree import Tree
8
8
 
9
9
  from falyx.action.action import Action
10
+ from falyx.action.action_mixins import ActionListMixin
10
11
  from falyx.action.base_action import BaseAction
11
12
  from falyx.action.fallback_action import FallbackAction
12
13
  from falyx.action.literal_input_action import LiteralInputAction
13
- from falyx.action.mixins import ActionListMixin
14
14
  from falyx.context import ExecutionContext, SharedContext
15
15
  from falyx.exceptions import EmptyChainError
16
16
  from falyx.execution_registry import ExecutionRegistry as er
@@ -47,7 +47,10 @@ class ChainedAction(BaseAction, ActionListMixin):
47
47
  def __init__(
48
48
  self,
49
49
  name: str,
50
- actions: Sequence[BaseAction | Callable[..., Any]] | None = None,
50
+ actions: (
51
+ Sequence[BaseAction | Callable[..., Any] | Callable[..., Awaitable[Any]]]
52
+ | None
53
+ ) = None,
51
54
  *,
52
55
  hooks: HookManager | None = None,
53
56
  inject_last_result: bool = False,
@@ -0,0 +1,196 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
+ """load_file_action.py"""
3
+ import csv
4
+ import json
5
+ import xml.etree.ElementTree as ET
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import toml
11
+ import yaml
12
+ from rich.tree import Tree
13
+
14
+ from falyx.action.action_types import FileType
15
+ from falyx.action.base_action import BaseAction
16
+ from falyx.context import ExecutionContext
17
+ from falyx.execution_registry import ExecutionRegistry as er
18
+ from falyx.hook_manager import HookType
19
+ from falyx.logger import logger
20
+ from falyx.themes import OneColors
21
+
22
+
23
+ class LoadFileAction(BaseAction):
24
+ """LoadFileAction allows loading and parsing files of various types."""
25
+
26
+ def __init__(
27
+ self,
28
+ name: str,
29
+ file_path: str | Path | None = None,
30
+ file_type: FileType | str = FileType.TEXT,
31
+ inject_last_result: bool = False,
32
+ inject_into: str = "file_path",
33
+ ):
34
+ super().__init__(
35
+ name=name, inject_last_result=inject_last_result, inject_into=inject_into
36
+ )
37
+ self._file_path = self._coerce_file_path(file_path)
38
+ self._file_type = self._coerce_file_type(file_type)
39
+
40
+ @property
41
+ def file_path(self) -> Path | None:
42
+ """Get the file path as a Path object."""
43
+ return self._file_path
44
+
45
+ @file_path.setter
46
+ def file_path(self, value: str | Path):
47
+ """Set the file path, converting to Path if necessary."""
48
+ self._file_path = self._coerce_file_path(value)
49
+
50
+ def _coerce_file_path(self, file_path: str | Path | None) -> Path | None:
51
+ """Coerce the file path to a Path object."""
52
+ if isinstance(file_path, Path):
53
+ return file_path
54
+ elif isinstance(file_path, str):
55
+ return Path(file_path)
56
+ elif file_path is None:
57
+ return None
58
+ else:
59
+ raise TypeError("file_path must be a string or Path object")
60
+
61
+ @property
62
+ def file_type(self) -> FileType:
63
+ """Get the file type."""
64
+ return self._file_type
65
+
66
+ @file_type.setter
67
+ def file_type(self, value: FileType | str):
68
+ """Set the file type, converting to FileType if necessary."""
69
+ self._file_type = self._coerce_file_type(value)
70
+
71
+ def _coerce_file_type(self, file_type: FileType | str) -> FileType:
72
+ """Coerce the file type to a FileType enum."""
73
+ if isinstance(file_type, FileType):
74
+ return file_type
75
+ elif isinstance(file_type, str):
76
+ return FileType(file_type)
77
+ else:
78
+ raise TypeError("file_type must be a FileType enum or string")
79
+
80
+ def get_infer_target(self) -> tuple[None, None]:
81
+ return None, None
82
+
83
+ def load_file(self) -> Any:
84
+ if self.file_path is None:
85
+ raise ValueError("file_path must be set before loading a file")
86
+ value: Any = None
87
+ try:
88
+ if self.file_type == FileType.TEXT:
89
+ value = self.file_path.read_text(encoding="UTF-8")
90
+ elif self.file_type == FileType.PATH:
91
+ value = self.file_path
92
+ elif self.file_type == FileType.JSON:
93
+ value = json.loads(self.file_path.read_text(encoding="UTF-8"))
94
+ elif self.file_type == FileType.TOML:
95
+ value = toml.loads(self.file_path.read_text(encoding="UTF-8"))
96
+ elif self.file_type == FileType.YAML:
97
+ value = yaml.safe_load(self.file_path.read_text(encoding="UTF-8"))
98
+ elif self.file_type == FileType.CSV:
99
+ with open(self.file_path, newline="", encoding="UTF-8") as csvfile:
100
+ reader = csv.reader(csvfile)
101
+ value = list(reader)
102
+ elif self.file_type == FileType.TSV:
103
+ with open(self.file_path, newline="", encoding="UTF-8") as tsvfile:
104
+ reader = csv.reader(tsvfile, delimiter="\t")
105
+ value = list(reader)
106
+ elif self.file_type == FileType.XML:
107
+ tree = ET.parse(self.file_path, parser=ET.XMLParser(encoding="UTF-8"))
108
+ root = tree.getroot()
109
+ value = ET.tostring(root, encoding="unicode")
110
+ else:
111
+ raise ValueError(f"Unsupported return type: {self.file_type}")
112
+
113
+ except Exception as error:
114
+ logger.error("Failed to parse %s: %s", self.file_path.name, error)
115
+ return value
116
+
117
+ async def _run(self, *args, **kwargs) -> Any:
118
+ context = ExecutionContext(name=self.name, args=args, kwargs=kwargs, action=self)
119
+ context.start_timer()
120
+ try:
121
+ await self.hooks.trigger(HookType.BEFORE, context)
122
+
123
+ if "file_path" in kwargs:
124
+ self.file_path = kwargs["file_path"]
125
+ elif self.inject_last_result and self.last_result:
126
+ self.file_path = self.last_result
127
+
128
+ if self.file_path is None:
129
+ raise ValueError("file_path must be set before loading a file")
130
+ elif not self.file_path.exists():
131
+ raise FileNotFoundError(f"File not found: {self.file_path}")
132
+ elif not self.file_path.is_file():
133
+ raise ValueError(f"Path is not a regular file: {self.file_path}")
134
+
135
+ result = self.load_file()
136
+ await self.hooks.trigger(HookType.ON_SUCCESS, context)
137
+ return result
138
+ except Exception as error:
139
+ context.exception = error
140
+ await self.hooks.trigger(HookType.ON_ERROR, context)
141
+ raise
142
+ finally:
143
+ context.stop_timer()
144
+ await self.hooks.trigger(HookType.AFTER, context)
145
+ await self.hooks.trigger(HookType.ON_TEARDOWN, context)
146
+ er.record(context)
147
+
148
+ async def preview(self, parent: Tree | None = None):
149
+ label = f"[{OneColors.GREEN}]📄 LoadFileAction[/] '{self.name}'"
150
+ tree = parent.add(label) if parent else Tree(label)
151
+
152
+ tree.add(f"[dim]Path:[/] {self.file_path}")
153
+ tree.add(f"[dim]Type:[/] {self.file_type.name if self.file_type else 'None'}")
154
+ if self.file_path is None:
155
+ tree.add(f"[{OneColors.DARK_RED_b}]❌ File path is not set[/]")
156
+ elif not self.file_path.exists():
157
+ tree.add(f"[{OneColors.DARK_RED_b}]❌ File does not exist[/]")
158
+ elif not self.file_path.is_file():
159
+ tree.add(f"[{OneColors.LIGHT_YELLOW_b}]⚠️ Not a regular file[/]")
160
+ else:
161
+ try:
162
+ stat = self.file_path.stat()
163
+ tree.add(f"[dim]Size:[/] {stat.st_size:,} bytes")
164
+ tree.add(
165
+ f"[dim]Modified:[/] {datetime.fromtimestamp(stat.st_mtime):%Y-%m-%d %H:%M:%S}"
166
+ )
167
+ tree.add(
168
+ f"[dim]Created:[/] {datetime.fromtimestamp(stat.st_ctime):%Y-%m-%d %H:%M:%S}"
169
+ )
170
+ if self.file_type == FileType.TEXT:
171
+ preview_lines = self.file_path.read_text(
172
+ encoding="UTF-8"
173
+ ).splitlines()[:10]
174
+ content_tree = tree.add("[dim]Preview (first 10 lines):[/]")
175
+ for line in preview_lines:
176
+ content_tree.add(f"[dim]{line}[/]")
177
+ elif self.file_type in {FileType.JSON, FileType.YAML, FileType.TOML}:
178
+ raw = self.load_file()
179
+ if raw is not None:
180
+ preview_str = (
181
+ json.dumps(raw, indent=2)
182
+ if isinstance(raw, dict)
183
+ else str(raw)
184
+ )
185
+ preview_lines = preview_str.splitlines()[:10]
186
+ content_tree = tree.add("[dim]Parsed preview:[/]")
187
+ for line in preview_lines:
188
+ content_tree.add(f"[dim]{line}[/]")
189
+ except Exception as e:
190
+ tree.add(f"[{OneColors.DARK_RED_b}]❌ Error reading file:[/] {e}")
191
+
192
+ if not parent:
193
+ self.console.print(tree)
194
+
195
+ def __str__(self) -> str:
196
+ return f"LoadFileAction(file_path={self.file_path}, file_type={self.file_type})"
@@ -7,12 +7,13 @@ import random
7
7
  from concurrent.futures import ProcessPoolExecutor
8
8
  from dataclasses import dataclass, field
9
9
  from functools import partial
10
- from typing import Any, Callable
10
+ from typing import Any, Callable, Sequence
11
11
 
12
12
  from rich.tree import Tree
13
13
 
14
14
  from falyx.action.base_action import BaseAction
15
15
  from falyx.context import ExecutionContext, SharedContext
16
+ from falyx.exceptions import EmptyPoolError
16
17
  from falyx.execution_registry import ExecutionRegistry as er
17
18
  from falyx.hook_manager import HookManager, HookType
18
19
  from falyx.logger import logger
@@ -37,7 +38,7 @@ class ProcessPoolAction(BaseAction):
37
38
  def __init__(
38
39
  self,
39
40
  name: str,
40
- actions: list[ProcessTask] | None = None,
41
+ actions: Sequence[ProcessTask] | None = None,
41
42
  *,
42
43
  hooks: HookManager | None = None,
43
44
  executor: ProcessPoolExecutor | None = None,
@@ -56,7 +57,7 @@ class ProcessPoolAction(BaseAction):
56
57
  if actions:
57
58
  self.set_actions(actions)
58
59
 
59
- def set_actions(self, actions: list[ProcessTask]) -> None:
60
+ def set_actions(self, actions: Sequence[ProcessTask]) -> None:
60
61
  """Replaces the current action list with a new one."""
61
62
  self.actions.clear()
62
63
  for action in actions:
@@ -78,6 +79,8 @@ class ProcessPoolAction(BaseAction):
78
79
  return None, None
79
80
 
80
81
  async def _run(self, *args, **kwargs) -> Any:
82
+ if not self.actions:
83
+ raise EmptyPoolError(f"[{self.name}] No actions to execute.")
81
84
  shared_context = SharedContext(name=self.name, action=self, is_parallel=True)
82
85
  if self.shared_context:
83
86
  shared_context.set_shared_result(self.shared_context.last_result())
@@ -4,13 +4,29 @@ from pathlib import Path
4
4
 
5
5
  from rich.tree import Tree
6
6
 
7
+ from falyx.action.action_types import FileType
7
8
  from falyx.action.base_action import BaseAction
8
9
 
9
10
 
10
11
  class SaveFileAction(BaseAction):
11
12
  """ """
12
13
 
13
- def __init__(self, name: str, file_path: str):
14
+ def __init__(
15
+ self,
16
+ name: str,
17
+ file_path: str,
18
+ input_type: str | FileType = "text",
19
+ output_type: str | FileType = "text",
20
+ ):
21
+ """
22
+ SaveFileAction allows saving data to a file.
23
+
24
+ Args:
25
+ name (str): Name of the action.
26
+ file_path (str | Path): Path to the file where data will be saved.
27
+ input_type (str | FileType): Type of data being saved (default is "text").
28
+ output_type (str | FileType): Type of data to save to the file (default is "text").
29
+ """
14
30
  super().__init__(name=name)
15
31
  self.file_path = file_path
16
32
 
@@ -107,7 +107,10 @@ class SelectFileAction(BaseAction):
107
107
  def _coerce_return_type(self, return_type: FileType | str) -> FileType:
108
108
  if isinstance(return_type, FileType):
109
109
  return return_type
110
- return FileType(return_type)
110
+ elif isinstance(return_type, str):
111
+ return FileType(return_type)
112
+ else:
113
+ raise TypeError("return_type must be a FileType enum or string")
111
114
 
112
115
  def get_options(self, files: list[Path]) -> dict[str, SelectionOption]:
113
116
  value: Any
@@ -19,7 +19,7 @@ in building robust interactive menus.
19
19
  from __future__ import annotations
20
20
 
21
21
  import shlex
22
- from typing import Any, Callable
22
+ from typing import Any, Awaitable, Callable
23
23
 
24
24
  from prompt_toolkit.formatted_text import FormattedText
25
25
  from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
@@ -105,7 +105,7 @@ class Command(BaseModel):
105
105
 
106
106
  key: str
107
107
  description: str
108
- action: BaseAction | Callable[..., Any]
108
+ action: BaseAction | Callable[..., Any] | Callable[..., Awaitable[Any]]
109
109
  args: tuple = ()
110
110
  kwargs: dict[str, Any] = Field(default_factory=dict)
111
111
  hidden: bool = False
@@ -30,5 +30,13 @@ class EmptyChainError(FalyxError):
30
30
  """Exception raised when the chain is empty."""
31
31
 
32
32
 
33
+ class EmptyGroupError(FalyxError):
34
+ """Exception raised when the chain is empty."""
35
+
36
+
37
+ class EmptyPoolError(FalyxError):
38
+ """Exception raised when the chain is empty."""
39
+
40
+
33
41
  class CommandArgumentError(FalyxError):
34
42
  """Exception raised when there is an error in the command argument parser."""
@@ -2,4 +2,4 @@
2
2
  """logger.py"""
3
3
  import logging
4
4
 
5
- logger = logging.getLogger("falyx")
5
+ logger: logging.Logger = logging.getLogger("falyx")
@@ -46,6 +46,7 @@ class Argument:
46
46
  ArgumentAction.STORE,
47
47
  ArgumentAction.APPEND,
48
48
  ArgumentAction.EXTEND,
49
+ ArgumentAction.ACTION,
49
50
  )
50
51
  and not self.positional
51
52
  ):
@@ -54,6 +55,7 @@ class Argument:
54
55
  ArgumentAction.STORE,
55
56
  ArgumentAction.APPEND,
56
57
  ArgumentAction.EXTEND,
58
+ ArgumentAction.ACTION,
57
59
  ) or isinstance(self.nargs, str):
58
60
  choice_text = self.dest
59
61
 
@@ -177,20 +177,19 @@ class CommandArgumentParser:
177
177
  else:
178
178
  choices = []
179
179
  for choice in choices:
180
- if not isinstance(choice, expected_type):
181
- try:
182
- coerce_value(choice, expected_type)
183
- except Exception as error:
184
- raise CommandArgumentError(
185
- f"Invalid choice {choice!r}: not coercible to {expected_type.__name__} error: {error}"
186
- ) from error
180
+ try:
181
+ coerce_value(choice, expected_type)
182
+ except Exception as error:
183
+ raise CommandArgumentError(
184
+ f"Invalid choice {choice!r}: not coercible to {expected_type.__name__} error: {error}"
185
+ ) from error
187
186
  return choices
188
187
 
189
188
  def _validate_default_type(
190
189
  self, default: Any, expected_type: type, dest: str
191
190
  ) -> None:
192
191
  """Validate the default value type."""
193
- if default is not None and not isinstance(default, expected_type):
192
+ if default is not None:
194
193
  try:
195
194
  coerce_value(default, expected_type)
196
195
  except Exception as error:
@@ -203,13 +202,12 @@ class CommandArgumentParser:
203
202
  ) -> None:
204
203
  if isinstance(default, list):
205
204
  for item in default:
206
- if not isinstance(item, expected_type):
207
- try:
208
- coerce_value(item, expected_type)
209
- except Exception as error:
210
- raise CommandArgumentError(
211
- f"Default list value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__} error: {error}"
212
- ) from error
205
+ try:
206
+ coerce_value(item, expected_type)
207
+ except Exception as error:
208
+ raise CommandArgumentError(
209
+ f"Default list value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__} error: {error}"
210
+ ) from error
213
211
 
214
212
  def _validate_resolver(
215
213
  self, action: ArgumentAction, resolver: BaseAction | None
@@ -422,22 +420,22 @@ class CommandArgumentParser:
422
420
  raise CommandArgumentError(
423
421
  f"Expected at least one value for '{spec.dest}'"
424
422
  )
425
- while i < len(args) and not args[i].startswith("-"):
423
+ while i < len(args) and args[i] not in self._keyword:
426
424
  values.append(args[i])
427
425
  i += 1
428
426
  assert values, "Expected at least one value for '+' nargs: shouldn't happen"
429
427
  return values, i
430
428
  elif spec.nargs == "*":
431
- while i < len(args) and not args[i].startswith("-"):
429
+ while i < len(args) and args[i] not in self._keyword:
432
430
  values.append(args[i])
433
431
  i += 1
434
432
  return values, i
435
433
  elif spec.nargs == "?":
436
- if i < len(args) and not args[i].startswith("-"):
434
+ if i < len(args) and args[i] not in self._keyword:
437
435
  return [args[i]], i + 1
438
436
  return [], i
439
437
  elif spec.nargs is None:
440
- if i < len(args) and not args[i].startswith("-"):
438
+ if i < len(args) and args[i] not in self._keyword:
441
439
  return [args[i]], i + 1
442
440
  return [], i
443
441
  assert False, "Invalid nargs value: shouldn't happen"
@@ -524,23 +522,142 @@ class CommandArgumentParser:
524
522
 
525
523
  return i
526
524
 
527
- def _expand_posix_bundling(self, args: list[str]) -> list[str]:
525
+ def _expand_posix_bundling(self, token: str) -> list[str] | str:
528
526
  """Expand POSIX-style bundled arguments into separate arguments."""
529
527
  expanded = []
530
- for token in args:
531
- if token.startswith("-") and not token.startswith("--") and len(token) > 2:
532
- # POSIX bundle
533
- # e.g. -abc -> -a -b -c
534
- for char in token[1:]:
535
- flag = f"-{char}"
536
- arg = self._flag_map.get(flag)
537
- if not arg:
538
- raise CommandArgumentError(f"Unrecognized option: {flag}")
539
- expanded.append(flag)
540
- else:
541
- expanded.append(token)
528
+ if token.startswith("-") and not token.startswith("--") and len(token) > 2:
529
+ # POSIX bundle
530
+ # e.g. -abc -> -a -b -c
531
+ for char in token[1:]:
532
+ flag = f"-{char}"
533
+ arg = self._flag_map.get(flag)
534
+ if not arg:
535
+ raise CommandArgumentError(f"Unrecognized option: {flag}")
536
+ expanded.append(flag)
537
+ else:
538
+ return token
542
539
  return expanded
543
540
 
541
+ async def _handle_token(
542
+ self,
543
+ token: str,
544
+ args: list[str],
545
+ i: int,
546
+ result: dict[str, Any],
547
+ positional_args: list[Argument],
548
+ consumed_positional_indices: set[int],
549
+ consumed_indices: set[int],
550
+ from_validate: bool = False,
551
+ ) -> int:
552
+ if token in self._keyword:
553
+ spec = self._keyword[token]
554
+ action = spec.action
555
+
556
+ if action == ArgumentAction.HELP:
557
+ if not from_validate:
558
+ self.render_help()
559
+ raise HelpSignal()
560
+ elif action == ArgumentAction.ACTION:
561
+ assert isinstance(
562
+ spec.resolver, BaseAction
563
+ ), "resolver should be an instance of BaseAction"
564
+ values, new_i = self._consume_nargs(args, i + 1, spec)
565
+ try:
566
+ typed_values = [coerce_value(value, spec.type) for value in values]
567
+ except ValueError as error:
568
+ raise CommandArgumentError(
569
+ f"Invalid value for '{spec.dest}': {error}"
570
+ ) from error
571
+ try:
572
+ result[spec.dest] = await spec.resolver(*typed_values)
573
+ except Exception as error:
574
+ raise CommandArgumentError(
575
+ f"[{spec.dest}] Action failed: {error}"
576
+ ) from error
577
+ consumed_indices.update(range(i, new_i))
578
+ i = new_i
579
+ elif action == ArgumentAction.STORE_TRUE:
580
+ result[spec.dest] = True
581
+ consumed_indices.add(i)
582
+ i += 1
583
+ elif action == ArgumentAction.STORE_FALSE:
584
+ result[spec.dest] = False
585
+ consumed_indices.add(i)
586
+ i += 1
587
+ elif action == ArgumentAction.COUNT:
588
+ result[spec.dest] = result.get(spec.dest, 0) + 1
589
+ consumed_indices.add(i)
590
+ i += 1
591
+ elif action == ArgumentAction.APPEND:
592
+ assert result.get(spec.dest) is not None, "dest should not be None"
593
+ values, new_i = self._consume_nargs(args, i + 1, spec)
594
+ try:
595
+ typed_values = [coerce_value(value, spec.type) for value in values]
596
+ except ValueError as error:
597
+ raise CommandArgumentError(
598
+ f"Invalid value for '{spec.dest}': {error}"
599
+ ) from error
600
+ if spec.nargs is None:
601
+ result[spec.dest].append(spec.type(values[0]))
602
+ else:
603
+ result[spec.dest].append(typed_values)
604
+ consumed_indices.update(range(i, new_i))
605
+ i = new_i
606
+ elif action == ArgumentAction.EXTEND:
607
+ assert result.get(spec.dest) is not None, "dest should not be None"
608
+ values, new_i = self._consume_nargs(args, i + 1, spec)
609
+ try:
610
+ typed_values = [coerce_value(value, spec.type) for value in values]
611
+ except ValueError as error:
612
+ raise CommandArgumentError(
613
+ f"Invalid value for '{spec.dest}': {error}"
614
+ ) from error
615
+ result[spec.dest].extend(typed_values)
616
+ consumed_indices.update(range(i, new_i))
617
+ i = new_i
618
+ else:
619
+ values, new_i = self._consume_nargs(args, i + 1, spec)
620
+ try:
621
+ typed_values = [coerce_value(value, spec.type) for value in values]
622
+ except ValueError as error:
623
+ raise CommandArgumentError(
624
+ f"Invalid value for '{spec.dest}': {error}"
625
+ ) from error
626
+ if not typed_values and spec.nargs not in ("*", "?"):
627
+ raise CommandArgumentError(
628
+ f"Expected at least one value for '{spec.dest}'"
629
+ )
630
+ if spec.nargs in (None, 1, "?") and spec.action != ArgumentAction.APPEND:
631
+ result[spec.dest] = (
632
+ typed_values[0] if len(typed_values) == 1 else typed_values
633
+ )
634
+ else:
635
+ result[spec.dest] = typed_values
636
+ consumed_indices.update(range(i, new_i))
637
+ i = new_i
638
+ elif token.startswith("-"):
639
+ # Handle unrecognized option
640
+ raise CommandArgumentError(f"Unrecognized flag: {token}")
641
+ else:
642
+ # Get the next flagged argument index if it exists
643
+ next_flagged_index = -1
644
+ for index, arg in enumerate(args[i:], start=i):
645
+ if arg in self._keyword:
646
+ next_flagged_index = index
647
+ break
648
+ print(f"next_flagged_index: {next_flagged_index}")
649
+ print(f"{self._keyword_list=}")
650
+ if next_flagged_index == -1:
651
+ next_flagged_index = len(args)
652
+ args_consumed = await self._consume_all_positional_args(
653
+ args[i:next_flagged_index],
654
+ result,
655
+ positional_args,
656
+ consumed_positional_indices,
657
+ )
658
+ i += args_consumed
659
+ return i
660
+
544
661
  async def parse_args(
545
662
  self, args: list[str] | None = None, from_validate: bool = False
546
663
  ) -> dict[str, Any]:
@@ -548,132 +665,29 @@ class CommandArgumentParser:
548
665
  if args is None:
549
666
  args = []
550
667
 
551
- args = self._expand_posix_bundling(args)
552
-
553
668
  result = {arg.dest: deepcopy(arg.default) for arg in self._arguments}
554
- positional_args = [arg for arg in self._arguments if arg.positional]
669
+ positional_args: list[Argument] = [
670
+ arg for arg in self._arguments if arg.positional
671
+ ]
555
672
  consumed_positional_indices: set[int] = set()
556
673
  consumed_indices: set[int] = set()
557
674
 
558
675
  i = 0
559
676
  while i < len(args):
560
- token = args[i]
561
- if token in self._keyword:
562
- spec = self._keyword[token]
563
- action = spec.action
564
-
565
- if action == ArgumentAction.HELP:
566
- if not from_validate:
567
- self.render_help()
568
- raise HelpSignal()
569
- elif action == ArgumentAction.ACTION:
570
- assert isinstance(
571
- spec.resolver, BaseAction
572
- ), "resolver should be an instance of BaseAction"
573
- values, new_i = self._consume_nargs(args, i + 1, spec)
574
- try:
575
- typed_values = [
576
- coerce_value(value, spec.type) for value in values
577
- ]
578
- except ValueError as error:
579
- raise CommandArgumentError(
580
- f"Invalid value for '{spec.dest}': {error}"
581
- ) from error
582
- try:
583
- result[spec.dest] = await spec.resolver(*typed_values)
584
- except Exception as error:
585
- raise CommandArgumentError(
586
- f"[{spec.dest}] Action failed: {error}"
587
- ) from error
588
- consumed_indices.update(range(i, new_i))
589
- i = new_i
590
- elif action == ArgumentAction.STORE_TRUE:
591
- result[spec.dest] = True
592
- consumed_indices.add(i)
593
- i += 1
594
- elif action == ArgumentAction.STORE_FALSE:
595
- result[spec.dest] = False
596
- consumed_indices.add(i)
597
- i += 1
598
- elif action == ArgumentAction.COUNT:
599
- result[spec.dest] = result.get(spec.dest, 0) + 1
600
- consumed_indices.add(i)
601
- i += 1
602
- elif action == ArgumentAction.APPEND:
603
- assert result.get(spec.dest) is not None, "dest should not be None"
604
- values, new_i = self._consume_nargs(args, i + 1, spec)
605
- try:
606
- typed_values = [
607
- coerce_value(value, spec.type) for value in values
608
- ]
609
- except ValueError as error:
610
- raise CommandArgumentError(
611
- f"Invalid value for '{spec.dest}': {error}"
612
- ) from error
613
- if spec.nargs is None:
614
- result[spec.dest].append(spec.type(values[0]))
615
- else:
616
- result[spec.dest].append(typed_values)
617
- consumed_indices.update(range(i, new_i))
618
- i = new_i
619
- elif action == ArgumentAction.EXTEND:
620
- assert result.get(spec.dest) is not None, "dest should not be None"
621
- values, new_i = self._consume_nargs(args, i + 1, spec)
622
- try:
623
- typed_values = [
624
- coerce_value(value, spec.type) for value in values
625
- ]
626
- except ValueError as error:
627
- raise CommandArgumentError(
628
- f"Invalid value for '{spec.dest}': {error}"
629
- ) from error
630
- result[spec.dest].extend(typed_values)
631
- consumed_indices.update(range(i, new_i))
632
- i = new_i
633
- else:
634
- values, new_i = self._consume_nargs(args, i + 1, spec)
635
- try:
636
- typed_values = [
637
- coerce_value(value, spec.type) for value in values
638
- ]
639
- except ValueError as error:
640
- raise CommandArgumentError(
641
- f"Invalid value for '{spec.dest}': {error}"
642
- ) from error
643
- if not typed_values and spec.nargs not in ("*", "?"):
644
- raise CommandArgumentError(
645
- f"Expected at least one value for '{spec.dest}'"
646
- )
647
- if (
648
- spec.nargs in (None, 1, "?")
649
- and spec.action != ArgumentAction.APPEND
650
- ):
651
- result[spec.dest] = (
652
- typed_values[0] if len(typed_values) == 1 else typed_values
653
- )
654
- else:
655
- result[spec.dest] = typed_values
656
- consumed_indices.update(range(i, new_i))
657
- i = new_i
658
- elif token.startswith("-"):
659
- # Handle unrecognized option
660
- raise CommandArgumentError(f"Unrecognized flag: {token}")
661
- else:
662
- # Get the next flagged argument index if it exists
663
- next_flagged_index = -1
664
- for index, arg in enumerate(args[i:], start=i):
665
- if arg.startswith("-"):
666
- next_flagged_index = index
667
- break
668
- if next_flagged_index == -1:
669
- next_flagged_index = len(args)
670
- args_consumed = await self._consume_all_positional_args(
671
- args[i:next_flagged_index],
672
- result,
673
- positional_args,
674
- consumed_positional_indices,
675
- )
676
- i += args_consumed
677
+ token = self._expand_posix_bundling(args[i])
678
+ if isinstance(token, list):
679
+ args[i : i + 1] = token
680
+ token = args[i]
681
+ i = await self._handle_token(
682
+ token,
683
+ args,
684
+ i,
685
+ result,
686
+ positional_args,
687
+ consumed_positional_indices,
688
+ consumed_indices,
689
+ from_validate=from_validate,
690
+ )
677
691
 
678
692
  # Required validation
679
693
  for spec in self._arguments:
@@ -797,6 +811,8 @@ class CommandArgumentParser:
797
811
  flags = arg.get_positional_text()
798
812
  arg_line = Text(f" {flags:<30} ")
799
813
  help_text = arg.help or ""
814
+ if help_text and len(flags) > 30:
815
+ help_text = f"\n{'':<33}{help_text}"
800
816
  arg_line.append(help_text)
801
817
  self.console.print(arg_line)
802
818
  self.console.print("[bold]options:[/bold]")
@@ -805,6 +821,8 @@ class CommandArgumentParser:
805
821
  flags_choice = f"{flags} {arg.get_choice_text()}"
806
822
  arg_line = Text(f" {flags_choice:<30} ")
807
823
  help_text = arg.help or ""
824
+ if help_text and len(flags_choice) > 30:
825
+ help_text = f"\n{'':<33}{help_text}"
808
826
  arg_line.append(help_text)
809
827
  self.console.print(arg_line)
810
828
 
@@ -33,7 +33,6 @@ def coerce_enum(value: Any, enum_type: EnumMeta) -> Any:
33
33
  pass
34
34
 
35
35
  base_type = type(next(iter(enum_type)).value)
36
- print(base_type)
37
36
  try:
38
37
  coerced_value = base_type(value)
39
38
  return enum_type(coerced_value)
@@ -2,14 +2,16 @@
2
2
  """protocols.py"""
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Any, Awaitable, Protocol, runtime_checkable
5
+ from typing import Any, Awaitable, Callable, Protocol, runtime_checkable
6
6
 
7
7
  from falyx.action.base_action import BaseAction
8
8
 
9
9
 
10
10
  @runtime_checkable
11
11
  class ActionFactoryProtocol(Protocol):
12
- async def __call__(self, *args: Any, **kwargs: Any) -> Awaitable[BaseAction]: ...
12
+ async def __call__(
13
+ self, *args: Any, **kwargs: Any
14
+ ) -> Callable[..., Awaitable[BaseAction]]: ...
13
15
 
14
16
 
15
17
  @runtime_checkable
@@ -0,0 +1 @@
1
+ __version__ = "0.1.54"
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "falyx"
3
- version = "0.1.53"
3
+ version = "0.1.54"
4
4
  description = "Reliable and introspectable async CLI action framework."
5
5
  authors = ["Roland Thomas Jr <roland@rtj.dev>"]
6
6
  license = "MIT"
@@ -27,6 +27,10 @@ black = { version = "^25.0", allow-prereleases = true }
27
27
  mypy = { version = "^1.0", allow-prereleases = true }
28
28
  isort = { version = "^5.0", allow-prereleases = true }
29
29
  pytest-cov = "^4.0"
30
+ mkdocs = "^1.6.1"
31
+ mkdocs-material = "^9.6.14"
32
+ mkdocstrings = {extras = ["python"], version = "^0.29.1"}
33
+ mike = "^2.1.3"
30
34
 
31
35
  [tool.poetry.scripts]
32
36
  falyx = "falyx.__main__:main"
@@ -1,28 +0,0 @@
1
- # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
- """load_file_action.py"""
3
- from pathlib import Path
4
-
5
- from rich.tree import Tree
6
-
7
- from falyx.action.base_action import BaseAction
8
-
9
-
10
- class LoadFileAction(BaseAction):
11
- """ """
12
-
13
- def __init__(self, name: str, file_path: str):
14
- super().__init__(name=name)
15
- self.file_path = file_path
16
-
17
- def get_infer_target(self) -> tuple[None, None]:
18
- return None, None
19
-
20
- async def _run(self, *args, **kwargs):
21
- raise NotImplementedError(
22
- "LoadFileAction is not finished yet... Use primatives instead..."
23
- )
24
-
25
- async def preview(self, parent: Tree | None = None): ...
26
-
27
- def __str__(self) -> str:
28
- return f"LoadFileAction(file_path={self.file_path})"
@@ -1 +0,0 @@
1
- __version__ = "0.1.53"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes