Files
falyx/falyx/action/save_file_action.py
Roland Thomas 7f63e16097 feat: Add module docs, Enum coercion, tracebacks, and toggle improvements
- Add comprehensive module docstrings across the codebase for better clarity and documentation.
- Refactor Enum classes (e.g., FileType, ConfirmType) to use `_missing_` for built-in coercion from strings.
- Add `encoding` attribute to `LoadFileAction`, `SaveFileAction`, and `SelectFileAction` for more flexible file handling.
- Enable lazy file loading by default in `SelectFileAction` to improve performance.
- Simplify bottom bar toggle behavior: all toggles now use `ctrl+<key>`, eliminating the need for key conflict checks with Falyx commands.
- Add `ignore_in_history` attribute to `Command` to refine how `ExecutionRegistry` identifies the last valid result.
- Improve History command output: now includes tracebacks when displaying exceptions.
2025-07-19 14:44:43 -04:00

297 lines
12 KiB
Python

# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `SaveFileAction`, a Falyx Action for writing structured or unstructured data
to a file in a variety of supported formats.
Supports overwrite control, automatic directory creation, and full lifecycle hook
integration. Compatible with chaining and injection of upstream results via
`inject_last_result`.
Supported formats: TEXT, JSON, YAML, TOML, CSV, TSV, XML
Key Features:
- Auto-serialization of Python data to structured formats
- Flexible path control with directory creation and overwrite handling
- Injection of data via chaining (`last_result`)
- Preview mode with file metadata visualization
Common use cases:
- Writing processed results to disk
- Logging artifacts from batch pipelines
- Exporting config or user input to JSON/YAML for reuse
"""
import csv
import json
import xml.etree.ElementTree as ET
from datetime import datetime
from pathlib import Path
from typing import Any, Literal
import toml
import yaml
from rich.tree import Tree
from falyx.action.action_types import FileType
from falyx.action.base_action import BaseAction
from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType
from falyx.logger import logger
from falyx.themes import OneColors
class SaveFileAction(BaseAction):
"""
Saves data to a file in the specified format.
`SaveFileAction` serializes and writes input data to disk using the format
defined by `file_type`. It supports plain text and structured formats like
JSON, YAML, TOML, CSV, TSV, and XML. Files may be overwritten or appended
based on settings, and parent directories are created if missing.
Data can be provided directly via the `data` argument or dynamically injected
from the previous Action using `inject_last_result`.
Key Features:
- Format-aware saving with validation
- Lifecycle hook support (before, success, error, after, teardown)
- Chain-compatible via last_result injection
- Supports safe overwrite behavior and preview diagnostics
Args:
name (str): Name of the action. Used for logging and debugging.
file_path (str | Path): Destination file path.
file_type (FileType | str): Output format (e.g., "json", "yaml", "text").
mode (Literal["w", "a"]): File mode—write or append. Default is "w".
encoding (str): Encoding to use when writing files (default: "UTF-8").
data (Any): Data to save. If omitted, uses last_result injection.
overwrite (bool): Whether to overwrite existing files. Default is True.
create_dirs (bool): Whether to auto-create parent directories.
inject_last_result (bool): Inject previous result as input if enabled.
inject_into (str): Name of kwarg to inject last_result into (default: "data").
Returns:
str: The full path to the saved file.
Raises:
FileExistsError: If the file exists and `overwrite` is False.
FileNotFoundError: If parent directory is missing and `create_dirs` is False.
ValueError: If data format is invalid for the target file type.
Exception: Any errors encountered during file writing.
Example:
SaveFileAction(
name="SaveOutput",
file_path="output/data.json",
file_type="json",
inject_last_result=True
)
"""
def __init__(
self,
name: str,
file_path: str,
file_type: FileType | str = FileType.TEXT,
mode: Literal["w", "a"] = "w",
encoding: str = "UTF-8",
data: Any = None,
overwrite: bool = True,
create_dirs: bool = True,
inject_last_result: bool = False,
inject_into: str = "data",
):
"""
SaveFileAction allows saving data to a file.
Args:
name (str): Name of the action.
file_path (str | Path): Path to the file where data will be saved.
file_type (FileType | str): Format to write to (e.g. TEXT, JSON, YAML).
mode (Literal["w", "a"]): File mode (default: "w").
encoding (str): Encoding to use when writing files (default: "UTF-8").
data (Any): Data to be saved (if not using inject_last_result).
overwrite (bool): Whether to overwrite the file if it exists.
create_dirs (bool): Whether to create parent directories if they do not exist.
inject_last_result (bool): Whether to inject result from previous action.
inject_into (str): Kwarg name to inject the last result as.
"""
super().__init__(
name=name, inject_last_result=inject_last_result, inject_into=inject_into
)
self._file_path = self._coerce_file_path(file_path)
self._file_type = FileType(file_type)
self.data = data
self.overwrite = overwrite
self.mode = mode
self.create_dirs = create_dirs
self.encoding = encoding
@property
def file_path(self) -> Path | None:
"""Get the file path as a Path object."""
return self._file_path
@file_path.setter
def file_path(self, value: str | Path):
"""Set the file path, converting to Path if necessary."""
self._file_path = self._coerce_file_path(value)
def _coerce_file_path(self, file_path: str | Path | None) -> Path | None:
"""Coerce the file path to a Path object."""
if isinstance(file_path, Path):
return file_path
elif isinstance(file_path, str):
return Path(file_path)
elif file_path is None:
return None
else:
raise TypeError("file_path must be a string or Path object")
@property
def file_type(self) -> FileType:
"""Get the file type."""
return self._file_type
def get_infer_target(self) -> tuple[None, None]:
return None, None
def _dict_to_xml(self, data: dict, root: ET.Element) -> None:
"""Convert a dictionary to XML format."""
for key, value in data.items():
if isinstance(value, dict):
sub_element = ET.SubElement(root, key)
self._dict_to_xml(value, sub_element)
elif isinstance(value, list):
for item in value:
item_element = ET.SubElement(root, key)
if isinstance(item, dict):
self._dict_to_xml(item, item_element)
else:
item_element.text = str(item)
else:
element = ET.SubElement(root, key)
element.text = str(value)
async def save_file(self, data: Any) -> None:
"""Save data to the specified file in the desired format."""
if self.file_path is None:
raise ValueError("file_path must be set before saving a file")
elif self.file_path.exists() and not self.overwrite:
raise FileExistsError(f"File already exists: {self.file_path}")
if self.file_path.parent and not self.file_path.parent.exists():
if self.create_dirs:
self.file_path.parent.mkdir(parents=True, exist_ok=True)
else:
raise FileNotFoundError(
f"Directory does not exist: {self.file_path.parent}"
)
try:
if self.file_type == FileType.TEXT:
self.file_path.write_text(data, encoding=self.encoding)
elif self.file_type == FileType.JSON:
self.file_path.write_text(
json.dumps(data, indent=4), encoding=self.encoding
)
elif self.file_type == FileType.TOML:
self.file_path.write_text(toml.dumps(data), encoding=self.encoding)
elif self.file_type == FileType.YAML:
self.file_path.write_text(yaml.dump(data), encoding=self.encoding)
elif self.file_type == FileType.CSV:
if not isinstance(data, list) or not all(
isinstance(row, list) for row in data
):
raise ValueError(
f"{self.file_type.name} file type requires a list of lists"
)
with open(
self.file_path, mode=self.mode, newline="", encoding=self.encoding
) as csvfile:
writer = csv.writer(csvfile)
writer.writerows(data)
elif self.file_type == FileType.TSV:
if not isinstance(data, list) or not all(
isinstance(row, list) for row in data
):
raise ValueError(
f"{self.file_type.name} file type requires a list of lists"
)
with open(
self.file_path, mode=self.mode, newline="", encoding=self.encoding
) as tsvfile:
writer = csv.writer(tsvfile, delimiter="\t")
writer.writerows(data)
elif self.file_type == FileType.XML:
if not isinstance(data, dict):
raise ValueError("XML file type requires data to be a dictionary")
root = ET.Element("root")
self._dict_to_xml(data, root)
tree = ET.ElementTree(root)
tree.write(self.file_path, encoding=self.encoding, xml_declaration=True)
else:
raise ValueError(f"Unsupported file type: {self.file_type}")
except Exception as error:
logger.error("Failed to save %s: %s", self.file_path.name, error)
raise
async def _run(self, *args, **kwargs):
combined_kwargs = self._maybe_inject_last_result(kwargs)
data = self.data or combined_kwargs.get(self.inject_into)
context = ExecutionContext(
name=self.name, args=args, kwargs=combined_kwargs, action=self
)
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
await self.save_file(data)
logger.debug("File saved successfully: %s", self.file_path)
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return str(self.file_path)
except Exception as error:
context.exception = error
await self.hooks.trigger(HookType.ON_ERROR, context)
raise
finally:
context.stop_timer()
await self.hooks.trigger(HookType.AFTER, context)
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
er.record(context)
async def preview(self, parent: Tree | None = None):
label = f"[{OneColors.CYAN}]💾 SaveFileAction[/] '{self.name}'"
tree = parent.add(label) if parent else Tree(label)
tree.add(f"[dim]Path:[/] {self.file_path}")
tree.add(f"[dim]Type:[/] {self.file_type.name}")
tree.add(f"[dim]Overwrite:[/] {self.overwrite}")
if self.file_path and self.file_path.exists():
if self.overwrite:
tree.add(f"[{OneColors.LIGHT_YELLOW}]⚠️ File will be overwritten[/]")
else:
tree.add(
f"[{OneColors.DARK_RED}]❌ File exists and overwrite is disabled[/]"
)
stat = self.file_path.stat()
tree.add(f"[dim]Size:[/] {stat.st_size:,} bytes")
tree.add(
f"[dim]Modified:[/] {datetime.fromtimestamp(stat.st_mtime):%Y-%m-%d %H:%M:%S}"
)
tree.add(
f"[dim]Created:[/] {datetime.fromtimestamp(stat.st_ctime):%Y-%m-%d %H:%M:%S}"
)
if not parent:
self.console.print(tree)
def __str__(self) -> str:
return f"SaveFileAction(file_path={self.file_path}, file_type={self.file_type})"