Files
app/backend/fluksio/flow/nodes/file.py
T
stroblmeandClaude Opus 5 60d7ec81c0 Rename the import package app to fluksio
A wheel whose top-level module is `app` collides with anything else in a
user's venv, so the package that is about to be published takes the name
it is published under. Only the Python package moves; the repo, the
Docker WORKDIR and the compose project keep theirs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 21:48:05 +02:00

99 lines
3.2 KiB
Python

"""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