"""File: read a file into the graph, or write one out of it. Confined to a directory the engine owns. A flow that could name any path would be a way to read the secrets store or overwrite a node's source. """ from __future__ import annotations import json import logging from collections.abc import Iterable from pathlib import Path from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field from fluksio.flow.messages import MessageSpec from fluksio.flow.nodes.base import Node logger = logging.getLogger(__name__) def files_root() -> Path: """Where flow-readable files live: beside the flow store, not inside it.""" from fluksio.core.config import settings return settings.FLOWS_DIR.parent / "files" def resolve(name: str) -> Path: """Turn a flow-supplied name into a path inside the sandbox, or refuse.""" root = files_root().resolve() root.mkdir(parents=True, exist_ok=True) target = (root / name).resolve() if target != root and root not in target.parents: raise ValueError(f"'{name}' is outside the files directory") return target class FileNode(Node): """Read or write a file under the engine's files directory.""" class Params(BaseModel): model_config = ConfigDict(extra="allow") path: str = Field(description="Path relative to the engine's files directory.") mode: Literal["read", "write", "append"] = "read" format: Literal["text", "json"] = Field( default="text", description="Parse or serialize the contents as JSON." ) newline: bool = Field( default=True, description="End each written record with a newline." ) __slots__ = ("cfg",) def __init__( self, requires: MessageSpec | Iterable[MessageSpec] = (), provides: MessageSpec | Iterable[MessageSpec] = (), params: dict[str, Any] | None = None, name: str | None = None, ): self.cfg = self.Params.model_validate(params or {}) super().__init__( f=self._act, requires=requires, provides=provides, params=params, name=name or f"file_{self.cfg.mode}", ) @property def idempotent(self) -> bool: # type: ignore[override] # Reading twice is harmless; appending twice writes the line twice. return self.cfg.mode == "read" def _act(self, params: dict[str, Any], **kwargs: Any) -> dict[str, Any] | None: target = resolve(self.cfg.path) if self.cfg.mode == "read": if not target.exists(): raise FileNotFoundError(f"'{self.cfg.path}' does not exist") raw = target.read_text() value = json.loads(raw) if self.cfg.format == "json" else raw return {spec.port: value for spec in self.output_ports} or None if not kwargs: return None value = next(iter(kwargs.values())) text = json.dumps(value) if self.cfg.format == "json" else str(value) if self.cfg.newline: text += "\n" target.parent.mkdir(parents=True, exist_ok=True) with target.open("a" if self.cfg.mode == "append" else "w") as handle: handle.write(text) return None