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 CHANGED
@@ -12,6 +12,7 @@ from .falyx import Falyx
12
12
 
13
13
  logger = logging.getLogger("falyx")
14
14
 
15
+
15
16
  __all__ = [
16
17
  "Falyx",
17
18
  "ExecutionRegistry",
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
  ]
@@ -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:
@@ -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 = Console(color_system="truecolor")
77
+ self.console: Console = console
77
78
  self.options_manager: OptionsManager | None = None
78
79
 
79
80
  if logging_hooks:
@@ -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
- self.hooks.register(hook_type, hook)
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
 
@@ -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
+ )
@@ -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
- 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()
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:
@@ -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
 
@@ -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
- input_type: str | FileType = "text",
19
- output_type: str | FileType = "text",
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
- 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").
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__(name=name)
31
- self.file_path = file_path
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
- raise NotImplementedError(
38
- "SaveFileAction is not finished yet... Use primitives instead..."
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
- async def preview(self, parent: Tree | None = None): ...
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})"