falyx 0.1.56__py3-none-any.whl → 0.1.57__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 +1 -0
- falyx/action/__init__.py +4 -0
- falyx/action/action_group.py +6 -0
- falyx/action/base_action.py +2 -1
- falyx/action/chained_action.py +7 -1
- falyx/action/confirm_action.py +217 -0
- falyx/action/load_file_action.py +7 -9
- falyx/action/menu_action.py +0 -7
- falyx/action/prompt_menu_action.py +0 -6
- falyx/action/save_file_action.py +199 -11
- falyx/action/select_file_action.py +0 -8
- falyx/action/selection_action.py +0 -8
- falyx/action/user_input_action.py +0 -7
- falyx/bottom_bar.py +2 -2
- falyx/command.py +1 -3
- falyx/config.py +1 -3
- falyx/console.py +5 -0
- falyx/context.py +3 -1
- falyx/execution_registry.py +1 -0
- falyx/falyx.py +5 -2
- falyx/init.py +1 -3
- falyx/parser/argument.py +1 -0
- falyx/parser/command_argument_parser.py +40 -20
- falyx/parser/utils.py +3 -2
- falyx/selection.py +1 -16
- falyx/validators.py +24 -0
- falyx/version.py +1 -1
- {falyx-0.1.56.dist-info → falyx-0.1.57.dist-info}/METADATA +1 -1
- {falyx-0.1.56.dist-info → falyx-0.1.57.dist-info}/RECORD +32 -31
- {falyx-0.1.56.dist-info → falyx-0.1.57.dist-info}/LICENSE +0 -0
- {falyx-0.1.56.dist-info → falyx-0.1.57.dist-info}/WHEEL +0 -0
- {falyx-0.1.56.dist-info → falyx-0.1.57.dist-info}/entry_points.txt +0 -0
falyx/__init__.py
CHANGED
falyx/action/__init__.py
CHANGED
@@ -10,6 +10,7 @@ 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
|
13
|
+
from .confirm_action import ConfirmAction
|
13
14
|
from .fallback_action import FallbackAction
|
14
15
|
from .http_action import HTTPAction
|
15
16
|
from .io_action import BaseIOAction
|
@@ -19,6 +20,7 @@ from .menu_action import MenuAction
|
|
19
20
|
from .process_action import ProcessAction
|
20
21
|
from .process_pool_action import ProcessPoolAction
|
21
22
|
from .prompt_menu_action import PromptMenuAction
|
23
|
+
from .save_file_action import SaveFileAction
|
22
24
|
from .select_file_action import SelectFileAction
|
23
25
|
from .selection_action import SelectionAction
|
24
26
|
from .shell_action import ShellAction
|
@@ -45,4 +47,6 @@ __all__ = [
|
|
45
47
|
"PromptMenuAction",
|
46
48
|
"ProcessPoolAction",
|
47
49
|
"LoadFileAction",
|
50
|
+
"SaveFileAction",
|
51
|
+
"ConfirmAction",
|
48
52
|
]
|
falyx/action/action_group.py
CHANGED
@@ -14,6 +14,7 @@ from falyx.exceptions import EmptyGroupError
|
|
14
14
|
from falyx.execution_registry import ExecutionRegistry as er
|
15
15
|
from falyx.hook_manager import Hook, HookManager, HookType
|
16
16
|
from falyx.logger import logger
|
17
|
+
from falyx.options_manager import OptionsManager
|
17
18
|
from falyx.parser.utils import same_argument_definitions
|
18
19
|
from falyx.themes.colors import OneColors
|
19
20
|
|
@@ -96,6 +97,11 @@ class ActionGroup(BaseAction, ActionListMixin):
|
|
96
97
|
for action in actions:
|
97
98
|
self.add_action(action)
|
98
99
|
|
100
|
+
def set_options_manager(self, options_manager: OptionsManager) -> None:
|
101
|
+
super().set_options_manager(options_manager)
|
102
|
+
for action in self.actions:
|
103
|
+
action.set_options_manager(options_manager)
|
104
|
+
|
99
105
|
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
|
100
106
|
arg_defs = same_argument_definitions(self.actions)
|
101
107
|
if arg_defs:
|
falyx/action/base_action.py
CHANGED
@@ -36,6 +36,7 @@ from typing import Any, Callable
|
|
36
36
|
from rich.console import Console
|
37
37
|
from rich.tree import Tree
|
38
38
|
|
39
|
+
from falyx.console import console
|
39
40
|
from falyx.context import SharedContext
|
40
41
|
from falyx.debug import register_debug_hooks
|
41
42
|
from falyx.hook_manager import Hook, HookManager, HookType
|
@@ -73,7 +74,7 @@ class BaseAction(ABC):
|
|
73
74
|
self.inject_into: str = inject_into
|
74
75
|
self._never_prompt: bool = never_prompt
|
75
76
|
self._skip_in_chain: bool = False
|
76
|
-
self.console =
|
77
|
+
self.console: Console = console
|
77
78
|
self.options_manager: OptionsManager | None = None
|
78
79
|
|
79
80
|
if logging_hooks:
|
falyx/action/chained_action.py
CHANGED
@@ -16,6 +16,7 @@ from falyx.exceptions import EmptyChainError
|
|
16
16
|
from falyx.execution_registry import ExecutionRegistry as er
|
17
17
|
from falyx.hook_manager import Hook, HookManager, HookType
|
18
18
|
from falyx.logger import logger
|
19
|
+
from falyx.options_manager import OptionsManager
|
19
20
|
from falyx.themes import OneColors
|
20
21
|
|
21
22
|
|
@@ -92,6 +93,11 @@ class ChainedAction(BaseAction, ActionListMixin):
|
|
92
93
|
for action in actions:
|
93
94
|
self.add_action(action)
|
94
95
|
|
96
|
+
def set_options_manager(self, options_manager: OptionsManager) -> None:
|
97
|
+
super().set_options_manager(options_manager)
|
98
|
+
for action in self.actions:
|
99
|
+
action.set_options_manager(options_manager)
|
100
|
+
|
95
101
|
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
|
96
102
|
if self.actions:
|
97
103
|
return self.actions[0].get_infer_target()
|
@@ -197,7 +203,7 @@ class ChainedAction(BaseAction, ActionListMixin):
|
|
197
203
|
|
198
204
|
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
|
199
205
|
"""Register a hook for all actions and sub-actions."""
|
200
|
-
|
206
|
+
super().register_hooks_recursively(hook_type, hook)
|
201
207
|
for action in self.actions:
|
202
208
|
action.register_hooks_recursively(hook_type, hook)
|
203
209
|
|
falyx/action/confirm_action.py
CHANGED
@@ -0,0 +1,217 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from enum import Enum
|
4
|
+
from typing import Any
|
5
|
+
|
6
|
+
from prompt_toolkit import PromptSession
|
7
|
+
from rich.tree import Tree
|
8
|
+
|
9
|
+
from falyx.action.base_action import BaseAction
|
10
|
+
from falyx.context import ExecutionContext
|
11
|
+
from falyx.execution_registry import ExecutionRegistry as er
|
12
|
+
from falyx.hook_manager import HookType
|
13
|
+
from falyx.logger import logger
|
14
|
+
from falyx.prompt_utils import confirm_async, should_prompt_user
|
15
|
+
from falyx.signals import CancelSignal
|
16
|
+
from falyx.themes import OneColors
|
17
|
+
from falyx.validators import word_validator, words_validator
|
18
|
+
|
19
|
+
|
20
|
+
class ConfirmType(Enum):
|
21
|
+
"""Enum for different confirmation types."""
|
22
|
+
|
23
|
+
YES_NO = "yes_no"
|
24
|
+
YES_CANCEL = "yes_cancel"
|
25
|
+
YES_NO_CANCEL = "yes_no_cancel"
|
26
|
+
TYPE_WORD = "type_word"
|
27
|
+
OK_CANCEL = "ok_cancel"
|
28
|
+
|
29
|
+
@classmethod
|
30
|
+
def choices(cls) -> list[ConfirmType]:
|
31
|
+
"""Return a list of all hook type choices."""
|
32
|
+
return list(cls)
|
33
|
+
|
34
|
+
def __str__(self) -> str:
|
35
|
+
"""Return the string representation of the confirm type."""
|
36
|
+
return self.value
|
37
|
+
|
38
|
+
|
39
|
+
class ConfirmAction(BaseAction):
|
40
|
+
"""
|
41
|
+
Action to confirm an operation with the user.
|
42
|
+
|
43
|
+
There are several ways to confirm an action, such as using a simple
|
44
|
+
yes/no prompt. You can also use a confirmation type that requires the user
|
45
|
+
to type a specific word or phrase to confirm the action, or use an OK/Cancel
|
46
|
+
dialog.
|
47
|
+
|
48
|
+
This action can be used to ensure that the user explicitly agrees to proceed
|
49
|
+
with an operation.
|
50
|
+
|
51
|
+
Attributes:
|
52
|
+
name (str): Name of the action.
|
53
|
+
message (str): The confirmation message to display.
|
54
|
+
confirm_type (ConfirmType | str): The type of confirmation to use.
|
55
|
+
Options include YES_NO, YES_CANCEL, YES_NO_CANCEL, TYPE_WORD, and OK_CANCEL.
|
56
|
+
prompt_session (PromptSession | None): The session to use for input.
|
57
|
+
confirm (bool): Whether to prompt the user for confirmation.
|
58
|
+
word (str): The word to type for TYPE_WORD confirmation.
|
59
|
+
return_last_result (bool): Whether to return the last result of the action.
|
60
|
+
"""
|
61
|
+
|
62
|
+
def __init__(
|
63
|
+
self,
|
64
|
+
name: str,
|
65
|
+
message: str = "Continue",
|
66
|
+
confirm_type: ConfirmType | str = ConfirmType.YES_NO,
|
67
|
+
prompt_session: PromptSession | None = None,
|
68
|
+
confirm: bool = True,
|
69
|
+
word: str = "CONFIRM",
|
70
|
+
return_last_result: bool = False,
|
71
|
+
inject_last_result: bool = True,
|
72
|
+
inject_into: str = "last_result",
|
73
|
+
):
|
74
|
+
"""
|
75
|
+
Initialize the ConfirmAction.
|
76
|
+
|
77
|
+
Args:
|
78
|
+
message (str): The confirmation message to display.
|
79
|
+
confirm_type (ConfirmType): The type of confirmation to use.
|
80
|
+
Options include YES_NO, YES_CANCEL, YES_NO_CANCEL, TYPE_WORD, and OK_CANCEL.
|
81
|
+
prompt_session (PromptSession | None): The session to use for input.
|
82
|
+
confirm (bool): Whether to prompt the user for confirmation.
|
83
|
+
word (str): The word to type for TYPE_WORD confirmation.
|
84
|
+
return_last_result (bool): Whether to return the last result of the action.
|
85
|
+
"""
|
86
|
+
super().__init__(
|
87
|
+
name=name,
|
88
|
+
inject_last_result=inject_last_result,
|
89
|
+
inject_into=inject_into,
|
90
|
+
)
|
91
|
+
self.message = message
|
92
|
+
self.confirm_type = self._coerce_confirm_type(confirm_type)
|
93
|
+
self.prompt_session = prompt_session or PromptSession()
|
94
|
+
self.confirm = confirm
|
95
|
+
self.word = word
|
96
|
+
self.return_last_result = return_last_result
|
97
|
+
|
98
|
+
def _coerce_confirm_type(self, confirm_type: ConfirmType | str) -> ConfirmType:
|
99
|
+
"""Coerce the confirm_type to a ConfirmType enum."""
|
100
|
+
if isinstance(confirm_type, ConfirmType):
|
101
|
+
return confirm_type
|
102
|
+
elif isinstance(confirm_type, str):
|
103
|
+
return ConfirmType(confirm_type)
|
104
|
+
return ConfirmType(confirm_type)
|
105
|
+
|
106
|
+
async def _confirm(self) -> bool:
|
107
|
+
"""Confirm the action with the user."""
|
108
|
+
match self.confirm_type:
|
109
|
+
case ConfirmType.YES_NO:
|
110
|
+
return await confirm_async(
|
111
|
+
self.message,
|
112
|
+
prefix="❓ ",
|
113
|
+
suffix=" [Y/n] > ",
|
114
|
+
session=self.prompt_session,
|
115
|
+
)
|
116
|
+
case ConfirmType.YES_NO_CANCEL:
|
117
|
+
answer = await self.prompt_session.prompt_async(
|
118
|
+
f"❓ {self.message} ([Y]es, [N]o, or [C]ancel to abort): ",
|
119
|
+
validator=words_validator(["Y", "N", "C"]),
|
120
|
+
)
|
121
|
+
if answer.upper() == "C":
|
122
|
+
raise CancelSignal(f"Action '{self.name}' was cancelled by the user.")
|
123
|
+
return answer.upper() == "Y"
|
124
|
+
case ConfirmType.TYPE_WORD:
|
125
|
+
answer = await self.prompt_session.prompt_async(
|
126
|
+
f"❓ {self.message} (type '{self.word}' to confirm or N/n): ",
|
127
|
+
validator=word_validator(self.word),
|
128
|
+
)
|
129
|
+
return answer.upper().strip() != "N"
|
130
|
+
case ConfirmType.YES_CANCEL:
|
131
|
+
answer = await confirm_async(
|
132
|
+
self.message,
|
133
|
+
prefix="❓ ",
|
134
|
+
suffix=" [Y/n] > ",
|
135
|
+
session=self.prompt_session,
|
136
|
+
)
|
137
|
+
if not answer:
|
138
|
+
raise CancelSignal(f"Action '{self.name}' was cancelled by the user.")
|
139
|
+
return answer
|
140
|
+
case ConfirmType.OK_CANCEL:
|
141
|
+
answer = await self.prompt_session.prompt_async(
|
142
|
+
f"❓ {self.message} ([O]k to continue, [C]ancel to abort): ",
|
143
|
+
validator=words_validator(["O", "C"]),
|
144
|
+
)
|
145
|
+
if answer.upper() == "C":
|
146
|
+
raise CancelSignal(f"Action '{self.name}' was cancelled by the user.")
|
147
|
+
return answer.upper() == "O"
|
148
|
+
case _:
|
149
|
+
raise ValueError(f"Unknown confirm_type: {self.confirm_type}")
|
150
|
+
|
151
|
+
def get_infer_target(self) -> tuple[None, None]:
|
152
|
+
return None, None
|
153
|
+
|
154
|
+
async def _run(self, *args, **kwargs) -> Any:
|
155
|
+
combined_kwargs = self._maybe_inject_last_result(kwargs)
|
156
|
+
context = ExecutionContext(
|
157
|
+
name=self.name, args=args, kwargs=combined_kwargs, action=self
|
158
|
+
)
|
159
|
+
context.start_timer()
|
160
|
+
try:
|
161
|
+
await self.hooks.trigger(HookType.BEFORE, context)
|
162
|
+
if (
|
163
|
+
not self.confirm
|
164
|
+
or self.options_manager
|
165
|
+
and not should_prompt_user(
|
166
|
+
confirm=self.confirm, options=self.options_manager
|
167
|
+
)
|
168
|
+
):
|
169
|
+
logger.debug(
|
170
|
+
"Skipping confirmation for action '%s' as 'confirm' is False or options manager indicates no prompt.",
|
171
|
+
self.name,
|
172
|
+
)
|
173
|
+
if self.return_last_result:
|
174
|
+
result = combined_kwargs[self.inject_into]
|
175
|
+
else:
|
176
|
+
result = True
|
177
|
+
else:
|
178
|
+
answer = await self._confirm()
|
179
|
+
if self.return_last_result and answer:
|
180
|
+
result = combined_kwargs[self.inject_into]
|
181
|
+
else:
|
182
|
+
result = answer
|
183
|
+
logger.debug("Action '%s' confirmed with result: %s", self.name, result)
|
184
|
+
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
185
|
+
return result
|
186
|
+
except Exception as error:
|
187
|
+
context.exception = error
|
188
|
+
await self.hooks.trigger(HookType.ON_ERROR, context)
|
189
|
+
raise
|
190
|
+
finally:
|
191
|
+
context.stop_timer()
|
192
|
+
await self.hooks.trigger(HookType.AFTER, context)
|
193
|
+
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
194
|
+
er.record(context)
|
195
|
+
|
196
|
+
async def preview(self, parent: Tree | None = None) -> None:
|
197
|
+
tree = (
|
198
|
+
Tree(
|
199
|
+
f"[{OneColors.CYAN_b}]ConfirmAction[/]: {self.name}",
|
200
|
+
guide_style=OneColors.BLUE_b,
|
201
|
+
)
|
202
|
+
if not parent
|
203
|
+
else parent.add(f"[{OneColors.CYAN_b}]ConfirmAction[/]: {self.name}")
|
204
|
+
)
|
205
|
+
tree.add(f"[bold]Message:[/] {self.message}")
|
206
|
+
tree.add(f"[bold]Type:[/] {self.confirm_type.value}")
|
207
|
+
tree.add(f"[bold]Prompt Required:[/] {'Yes' if self.confirm else 'No'}")
|
208
|
+
if self.confirm_type == ConfirmType.TYPE_WORD:
|
209
|
+
tree.add(f"[bold]Confirmation Word:[/] {self.word}")
|
210
|
+
if parent is None:
|
211
|
+
self.console.print(tree)
|
212
|
+
|
213
|
+
def __str__(self) -> str:
|
214
|
+
return (
|
215
|
+
f"ConfirmAction(name={self.name}, message={self.message}, "
|
216
|
+
f"confirm_type={self.confirm_type})"
|
217
|
+
)
|
falyx/action/load_file_action.py
CHANGED
@@ -80,9 +80,14 @@ class LoadFileAction(BaseAction):
|
|
80
80
|
def get_infer_target(self) -> tuple[None, None]:
|
81
81
|
return None, None
|
82
82
|
|
83
|
-
def load_file(self) -> Any:
|
83
|
+
async def load_file(self) -> Any:
|
84
|
+
"""Load and parse the file based on its type."""
|
84
85
|
if self.file_path is None:
|
85
86
|
raise ValueError("file_path must be set before loading a file")
|
87
|
+
elif not self.file_path.exists():
|
88
|
+
raise FileNotFoundError(f"File not found: {self.file_path}")
|
89
|
+
elif not self.file_path.is_file():
|
90
|
+
raise ValueError(f"Path is not a regular file: {self.file_path}")
|
86
91
|
value: Any = None
|
87
92
|
try:
|
88
93
|
if self.file_type == FileType.TEXT:
|
@@ -125,14 +130,7 @@ class LoadFileAction(BaseAction):
|
|
125
130
|
elif self.inject_last_result and self.last_result:
|
126
131
|
self.file_path = self.last_result
|
127
132
|
|
128
|
-
|
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()
|
133
|
+
result = await self.load_file()
|
136
134
|
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
137
135
|
return result
|
138
136
|
except Exception as error:
|
falyx/action/menu_action.py
CHANGED
@@ -3,7 +3,6 @@
|
|
3
3
|
from typing import Any
|
4
4
|
|
5
5
|
from prompt_toolkit import PromptSession
|
6
|
-
from rich.console import Console
|
7
6
|
from rich.table import Table
|
8
7
|
from rich.tree import Tree
|
9
8
|
|
@@ -33,7 +32,6 @@ class MenuAction(BaseAction):
|
|
33
32
|
default_selection: str = "",
|
34
33
|
inject_last_result: bool = False,
|
35
34
|
inject_into: str = "last_result",
|
36
|
-
console: Console | None = None,
|
37
35
|
prompt_session: PromptSession | None = None,
|
38
36
|
never_prompt: bool = False,
|
39
37
|
include_reserved: bool = True,
|
@@ -51,10 +49,6 @@ class MenuAction(BaseAction):
|
|
51
49
|
self.columns = columns
|
52
50
|
self.prompt_message = prompt_message
|
53
51
|
self.default_selection = default_selection
|
54
|
-
if isinstance(console, Console):
|
55
|
-
self.console = console
|
56
|
-
elif console:
|
57
|
-
raise ValueError("`console` must be an instance of `rich.console.Console`")
|
58
52
|
self.prompt_session = prompt_session or PromptSession()
|
59
53
|
self.include_reserved = include_reserved
|
60
54
|
self.show_table = show_table
|
@@ -115,7 +109,6 @@ class MenuAction(BaseAction):
|
|
115
109
|
self.menu_options.keys(),
|
116
110
|
table,
|
117
111
|
default_selection=self.default_selection,
|
118
|
-
console=self.console,
|
119
112
|
prompt_session=self.prompt_session,
|
120
113
|
prompt_message=self.prompt_message,
|
121
114
|
show_table=self.show_table,
|
@@ -4,7 +4,6 @@ from typing import Any
|
|
4
4
|
|
5
5
|
from prompt_toolkit import PromptSession
|
6
6
|
from prompt_toolkit.formatted_text import FormattedText, merge_formatted_text
|
7
|
-
from rich.console import Console
|
8
7
|
from rich.tree import Tree
|
9
8
|
|
10
9
|
from falyx.action.base_action import BaseAction
|
@@ -29,7 +28,6 @@ class PromptMenuAction(BaseAction):
|
|
29
28
|
default_selection: str = "",
|
30
29
|
inject_last_result: bool = False,
|
31
30
|
inject_into: str = "last_result",
|
32
|
-
console: Console | None = None,
|
33
31
|
prompt_session: PromptSession | None = None,
|
34
32
|
never_prompt: bool = False,
|
35
33
|
include_reserved: bool = True,
|
@@ -43,10 +41,6 @@ class PromptMenuAction(BaseAction):
|
|
43
41
|
self.menu_options = menu_options
|
44
42
|
self.prompt_message = prompt_message
|
45
43
|
self.default_selection = default_selection
|
46
|
-
if isinstance(console, Console):
|
47
|
-
self.console = console
|
48
|
-
elif console:
|
49
|
-
raise ValueError("`console` must be an instance of `rich.console.Console`")
|
50
44
|
self.prompt_session = prompt_session or PromptSession()
|
51
45
|
self.include_reserved = include_reserved
|
52
46
|
|
falyx/action/save_file_action.py
CHANGED
@@ -1,22 +1,44 @@
|
|
1
1
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
2
2
|
"""save_file_action.py"""
|
3
|
+
import csv
|
4
|
+
import json
|
5
|
+
import xml.etree.ElementTree as ET
|
6
|
+
from datetime import datetime
|
3
7
|
from pathlib import Path
|
8
|
+
from typing import Any, Literal
|
4
9
|
|
10
|
+
import toml
|
11
|
+
import yaml
|
5
12
|
from rich.tree import Tree
|
6
13
|
|
7
14
|
from falyx.action.action_types import FileType
|
8
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
|
9
21
|
|
10
22
|
|
11
23
|
class SaveFileAction(BaseAction):
|
12
|
-
"""
|
24
|
+
"""
|
25
|
+
SaveFileAction saves data to a file in the specified format (e.g., TEXT, JSON, YAML).
|
26
|
+
Supports overwrite control and integrates with chaining workflows via inject_last_result.
|
27
|
+
|
28
|
+
Supported types: TEXT, JSON, YAML, TOML, CSV, TSV, XML
|
29
|
+
|
30
|
+
If the file exists and overwrite is False, the action will raise a FileExistsError.
|
31
|
+
"""
|
13
32
|
|
14
33
|
def __init__(
|
15
34
|
self,
|
16
35
|
name: str,
|
17
36
|
file_path: str,
|
18
|
-
|
19
|
-
|
37
|
+
file_type: FileType | str = FileType.TEXT,
|
38
|
+
mode: Literal["w", "a"] = "w",
|
39
|
+
inject_last_result: bool = True,
|
40
|
+
inject_into: str = "data",
|
41
|
+
overwrite: bool = True,
|
20
42
|
):
|
21
43
|
"""
|
22
44
|
SaveFileAction allows saving data to a file.
|
@@ -24,21 +46,187 @@ class SaveFileAction(BaseAction):
|
|
24
46
|
Args:
|
25
47
|
name (str): Name of the action.
|
26
48
|
file_path (str | Path): Path to the file where data will be saved.
|
27
|
-
|
28
|
-
|
49
|
+
file_type (FileType | str): Format to write to (e.g. TEXT, JSON, YAML).
|
50
|
+
inject_last_result (bool): Whether to inject result from previous action.
|
51
|
+
inject_into (str): Kwarg name to inject the last result as.
|
52
|
+
overwrite (bool): Whether to overwrite the file if it exists.
|
29
53
|
"""
|
30
|
-
super().__init__(
|
31
|
-
|
54
|
+
super().__init__(
|
55
|
+
name=name, inject_last_result=inject_last_result, inject_into=inject_into
|
56
|
+
)
|
57
|
+
self._file_path = self._coerce_file_path(file_path)
|
58
|
+
self._file_type = self._coerce_file_type(file_type)
|
59
|
+
self.overwrite = overwrite
|
60
|
+
self.mode = mode
|
61
|
+
|
62
|
+
@property
|
63
|
+
def file_path(self) -> Path | None:
|
64
|
+
"""Get the file path as a Path object."""
|
65
|
+
return self._file_path
|
66
|
+
|
67
|
+
@file_path.setter
|
68
|
+
def file_path(self, value: str | Path):
|
69
|
+
"""Set the file path, converting to Path if necessary."""
|
70
|
+
self._file_path = self._coerce_file_path(value)
|
71
|
+
|
72
|
+
def _coerce_file_path(self, file_path: str | Path | None) -> Path | None:
|
73
|
+
"""Coerce the file path to a Path object."""
|
74
|
+
if isinstance(file_path, Path):
|
75
|
+
return file_path
|
76
|
+
elif isinstance(file_path, str):
|
77
|
+
return Path(file_path)
|
78
|
+
elif file_path is None:
|
79
|
+
return None
|
80
|
+
else:
|
81
|
+
raise TypeError("file_path must be a string or Path object")
|
82
|
+
|
83
|
+
@property
|
84
|
+
def file_type(self) -> FileType:
|
85
|
+
"""Get the file type."""
|
86
|
+
return self._file_type
|
87
|
+
|
88
|
+
@file_type.setter
|
89
|
+
def file_type(self, value: FileType | str):
|
90
|
+
"""Set the file type, converting to FileType if necessary."""
|
91
|
+
self._file_type = self._coerce_file_type(value)
|
92
|
+
|
93
|
+
def _coerce_file_type(self, file_type: FileType | str) -> FileType:
|
94
|
+
"""Coerce the file type to a FileType enum."""
|
95
|
+
if isinstance(file_type, FileType):
|
96
|
+
return file_type
|
97
|
+
elif isinstance(file_type, str):
|
98
|
+
return FileType(file_type)
|
99
|
+
else:
|
100
|
+
raise TypeError("file_type must be a FileType enum or string")
|
32
101
|
|
33
102
|
def get_infer_target(self) -> tuple[None, None]:
|
34
103
|
return None, None
|
35
104
|
|
105
|
+
def _dict_to_xml(self, data: dict, root: ET.Element) -> None:
|
106
|
+
"""Convert a dictionary to XML format."""
|
107
|
+
for key, value in data.items():
|
108
|
+
if isinstance(value, dict):
|
109
|
+
sub_element = ET.SubElement(root, key)
|
110
|
+
self._dict_to_xml(value, sub_element)
|
111
|
+
elif isinstance(value, list):
|
112
|
+
for item in value:
|
113
|
+
item_element = ET.SubElement(root, key)
|
114
|
+
if isinstance(item, dict):
|
115
|
+
self._dict_to_xml(item, item_element)
|
116
|
+
else:
|
117
|
+
item_element.text = str(item)
|
118
|
+
else:
|
119
|
+
element = ET.SubElement(root, key)
|
120
|
+
element.text = str(value)
|
121
|
+
|
122
|
+
async def save_file(self, data: Any) -> None:
|
123
|
+
"""Save data to the specified file in the desired format."""
|
124
|
+
if self.file_path is None:
|
125
|
+
raise ValueError("file_path must be set before saving a file")
|
126
|
+
elif self.file_path.exists() and not self.overwrite:
|
127
|
+
raise FileExistsError(f"File already exists: {self.file_path}")
|
128
|
+
|
129
|
+
try:
|
130
|
+
if self.file_type == FileType.TEXT:
|
131
|
+
self.file_path.write_text(data, encoding="UTF-8")
|
132
|
+
elif self.file_type == FileType.JSON:
|
133
|
+
self.file_path.write_text(json.dumps(data, indent=4), encoding="UTF-8")
|
134
|
+
elif self.file_type == FileType.TOML:
|
135
|
+
self.file_path.write_text(toml.dumps(data), encoding="UTF-8")
|
136
|
+
elif self.file_type == FileType.YAML:
|
137
|
+
self.file_path.write_text(yaml.dump(data), encoding="UTF-8")
|
138
|
+
elif self.file_type == FileType.CSV:
|
139
|
+
if not isinstance(data, list) or not all(
|
140
|
+
isinstance(row, list) for row in data
|
141
|
+
):
|
142
|
+
raise ValueError(
|
143
|
+
f"{self.file_type.name} file type requires a list of lists"
|
144
|
+
)
|
145
|
+
with open(
|
146
|
+
self.file_path, mode=self.mode, newline="", encoding="UTF-8"
|
147
|
+
) as csvfile:
|
148
|
+
writer = csv.writer(csvfile)
|
149
|
+
writer.writerows(data)
|
150
|
+
elif self.file_type == FileType.TSV:
|
151
|
+
if not isinstance(data, list) or not all(
|
152
|
+
isinstance(row, list) for row in data
|
153
|
+
):
|
154
|
+
raise ValueError(
|
155
|
+
f"{self.file_type.name} file type requires a list of lists"
|
156
|
+
)
|
157
|
+
with open(
|
158
|
+
self.file_path, mode=self.mode, newline="", encoding="UTF-8"
|
159
|
+
) as tsvfile:
|
160
|
+
writer = csv.writer(tsvfile, delimiter="\t")
|
161
|
+
writer.writerows(data)
|
162
|
+
elif self.file_type == FileType.XML:
|
163
|
+
if not isinstance(data, dict):
|
164
|
+
raise ValueError("XML file type requires data to be a dictionary")
|
165
|
+
root = ET.Element("root")
|
166
|
+
self._dict_to_xml(data, root)
|
167
|
+
tree = ET.ElementTree(root)
|
168
|
+
tree.write(self.file_path, encoding="UTF-8", xml_declaration=True)
|
169
|
+
else:
|
170
|
+
raise ValueError(f"Unsupported file type: {self.file_type}")
|
171
|
+
|
172
|
+
except Exception as error:
|
173
|
+
logger.error("Failed to save %s: %s", self.file_path.name, error)
|
174
|
+
raise
|
175
|
+
|
36
176
|
async def _run(self, *args, **kwargs):
|
37
|
-
|
38
|
-
|
177
|
+
combined_kwargs = self._maybe_inject_last_result(kwargs)
|
178
|
+
data = combined_kwargs.get(self.inject_into)
|
179
|
+
|
180
|
+
context = ExecutionContext(
|
181
|
+
name=self.name, args=args, kwargs=combined_kwargs, action=self
|
39
182
|
)
|
183
|
+
context.start_timer()
|
184
|
+
|
185
|
+
try:
|
186
|
+
await self.hooks.trigger(HookType.BEFORE, context)
|
187
|
+
|
188
|
+
await self.save_file(data)
|
189
|
+
logger.debug("File saved successfully: %s", self.file_path)
|
190
|
+
|
191
|
+
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
192
|
+
return str(self.file_path)
|
193
|
+
|
194
|
+
except Exception as error:
|
195
|
+
context.exception = error
|
196
|
+
await self.hooks.trigger(HookType.ON_ERROR, context)
|
197
|
+
raise
|
198
|
+
finally:
|
199
|
+
context.stop_timer()
|
200
|
+
await self.hooks.trigger(HookType.AFTER, context)
|
201
|
+
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
202
|
+
er.record(context)
|
203
|
+
|
204
|
+
async def preview(self, parent: Tree | None = None):
|
205
|
+
label = f"[{OneColors.CYAN}]💾 SaveFileAction[/] '{self.name}'"
|
206
|
+
tree = parent.add(label) if parent else Tree(label)
|
207
|
+
|
208
|
+
tree.add(f"[dim]Path:[/] {self.file_path}")
|
209
|
+
tree.add(f"[dim]Type:[/] {self.file_type.name}")
|
210
|
+
tree.add(f"[dim]Overwrite:[/] {self.overwrite}")
|
211
|
+
|
212
|
+
if self.file_path and self.file_path.exists():
|
213
|
+
if self.overwrite:
|
214
|
+
tree.add(f"[{OneColors.LIGHT_YELLOW}]⚠️ File will be overwritten[/]")
|
215
|
+
else:
|
216
|
+
tree.add(
|
217
|
+
f"[{OneColors.DARK_RED}]❌ File exists and overwrite is disabled[/]"
|
218
|
+
)
|
219
|
+
stat = self.file_path.stat()
|
220
|
+
tree.add(f"[dim]Size:[/] {stat.st_size:,} bytes")
|
221
|
+
tree.add(
|
222
|
+
f"[dim]Modified:[/] {datetime.fromtimestamp(stat.st_mtime):%Y-%m-%d %H:%M:%S}"
|
223
|
+
)
|
224
|
+
tree.add(
|
225
|
+
f"[dim]Created:[/] {datetime.fromtimestamp(stat.st_ctime):%Y-%m-%d %H:%M:%S}"
|
226
|
+
)
|
40
227
|
|
41
|
-
|
228
|
+
if not parent:
|
229
|
+
self.console.print(tree)
|
42
230
|
|
43
231
|
def __str__(self) -> str:
|
44
|
-
return f"SaveFileAction(file_path={self.file_path})"
|
232
|
+
return f"SaveFileAction(file_path={self.file_path}, file_type={self.file_type})"
|