Files
app/backend/fluksio/flow/messages.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

250 lines
9.8 KiB
Python

"""Message specifications: the typed contract between nodes.
A node declares ports; each port binds to a message name. The message name is
the wiring: a node consuming ``heating.temperature`` receives whatever any node
provides under that name. Names are namespaced per flow — a bare name is
qualified with the owning flow (``temperature`` in flow ``heating`` becomes
``heating.temperature``), a dotted name is used as written, which is how flows
consume each other's messages.
"""
from __future__ import annotations
import json
from enum import Enum
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, model_validator
class DType(str, Enum):
"""Serializable payload types.
The scalars carry what a single reading can say. The three structured ones
are declared shapes rather than "some JSON": a widget or a downstream node
knows what it is getting before anything runs, which is what lets the
dashboard picker offer a message and refuse a wrong binding.
Binary payloads — tensors, checkpoints, images — travel as ``artifact``:
the bytes go to the artifact store and the message carries a reference to
them. That keeps everything on the wire JSON, which is what the state
backend, the queue and the worker protocol all rely on, and it means a
thirty-megabyte checkpoint never sits in Redis. Inline codecs would only be
needed for payloads too small to be worth a round trip, and nothing asks
for that yet.
"""
FLOAT = "float"
INT = "int"
STR = "str"
BOOL = "bool"
JSON = "json"
#: ``{"lines": [{"label": str, "points": [[ts, value], ...]}], ...}``.
#: Keys beside ``lines`` are carried through untouched — a chart's query
#: puts the range and interval it asked for there and reads them back.
SERIES = "series"
#: Flat named scalars: ``{"title": "Boiler", "severity": "error"}``.
RECORD = "record"
#: Ordered items of one declared shape; see :attr:`MessageSpec.item`.
LIST = "list"
#: A reference to stored bytes:
#: ``{"digest": "sha256:…", "size": int, "media_type": str, "name": str}``.
ARTIFACT = "artifact"
_JSON_TYPES = (dict, list, str, int, float, bool, type(None))
#: What a record may hold. Nesting is deliberately out: a record that can
#: contain a record is a schema language, and the shape stops being readable
#: from the declaration alone.
_SCALARS = (str, int, float, bool)
#: Item types a list may declare. Recursion is refused for the same reason.
_ITEM_TYPES = frozenset(
{DType.FLOAT, DType.INT, DType.STR, DType.BOOL, DType.JSON, DType.RECORD}
)
def _is_number(value: Any) -> bool:
"""A measurement. bool is an int subclass; a flag is not a number here."""
return isinstance(value, (int, float)) and not isinstance(value, bool)
def _is_record(value: Any) -> bool:
return isinstance(value, dict) and all(
isinstance(key, str) and (item is None or isinstance(item, _SCALARS))
for key, item in value.items()
)
def _is_series(value: Any) -> bool:
"""Labelled lines of ``(timestamp, value)`` pairs.
Checked all the way down. That is O(n) in the number of points, but so is
the JSON encoding every message already pays for.
"""
if not isinstance(value, dict) or not isinstance(value.get("lines"), list):
return False
return all(
isinstance(line, dict)
and isinstance(line.get("label"), str)
and isinstance(line.get("points"), list)
and all(
isinstance(point, (list, tuple))
and len(point) == 2
and _is_number(point[0])
and _is_number(point[1])
for point in line["points"]
)
for line in value["lines"]
)
def _is_artifact(value: Any) -> bool:
"""A reference to stored bytes, not the bytes themselves.
The digest is what makes it one: it names content rather than a location,
so the same file produced twice is stored once and a reference stays valid
wherever the store is reachable from.
"""
return (
isinstance(value, dict)
and isinstance(value.get("digest"), str)
and value["digest"].startswith("sha256:")
and isinstance(value.get("size"), int)
)
def _matches(dtype: DType, value: Any) -> bool:
"""Whether one value satisfies a scalar or record type."""
if dtype is DType.BOOL:
return isinstance(value, bool)
if dtype is DType.INT:
return isinstance(value, int) and not isinstance(value, bool)
if dtype is DType.FLOAT:
return _is_number(value)
if dtype is DType.STR:
return isinstance(value, str)
if dtype is DType.RECORD:
return _is_record(value)
if dtype is DType.ARTIFACT:
return _is_artifact(value)
return isinstance(value, _JSON_TYPES)
class MessageSpec(BaseModel):
"""A single port of a node, and the message it is bound to.
:param name: The message this port binds to. Bare names are qualified with
the flow name at load time; empty means the port is unbound.
:param port: The identifier the node function sees. Defaults to the last
segment of ``name``, so unqualified flows read naturally.
:param dtype: Payload type, validated on every message that passes through.
:param item: The type of each item of a ``list`` port, ignored otherwise.
Unset means ``record``, which is what the agenda and forecast widgets
read; ``float`` is the numeric list a pipeline passes around. A list of
lists, or of series, is refused — one declared level is the point.
:param interval: Deliver at most every this many seconds; 0 is every time.
On an output it holds back publishing, on an input it holds back waking
the node. The value is never lost — state keeps the latest — only the
delivery is skipped.
:param trigger: Whether arriving values wake the node. An input with this
off is read when the node runs for some other reason, but never causes
a run and never makes the node wait — which is how a node reads a
message it also produces without depending on itself.
:param stream: On an output, that this port produces repeatedly *during* one
execution rather than once at the end — a training loss, a progress
fraction. A node emits on it by being a generator and yielding, or by
calling ``fluksio.emit``. What it means downstream is nothing special:
a value published mid-execution is a value like any other. What it
means to a run is that the whole series is kept, which is how a run's
metrics are simply its streaming outputs rather than something logged
beside them.
"""
model_config = ConfigDict(frozen=True)
name: str = ""
port: str = ""
dtype: DType = DType.FLOAT
item: DType | None = None
interval: float = Field(default=0, ge=0)
trigger: bool = True
stream: bool = False
@model_validator(mode="after")
def _default_port(self) -> MessageSpec:
if not self.port and self.name:
object.__setattr__(self, "port", self.name.rsplit(".", 1)[-1])
if self.item is not None and self.item not in _ITEM_TYPES:
raise ValueError(f"a list cannot hold '{self.item.value}' items")
return self
@property
def item_dtype(self) -> DType:
"""What each item of a ``list`` port is, declared or defaulted."""
return self.item or DType.RECORD
def check(self, value: Any) -> None:
"""Raise if ``value`` does not match this port's declared type."""
where = self.name or self.port
if self.dtype is DType.SERIES:
ok = _is_series(value)
elif self.dtype is DType.LIST:
if not isinstance(value, list):
raise TypeError(f"{where}: expected list, got {type(value).__name__}")
item = self.item_dtype
for index, element in enumerate(value):
if not _matches(item, element):
raise TypeError(
f"{where}: expected list[{item.value}], got "
f"{type(element).__name__} at index {index}"
)
return
else:
ok = _matches(self.dtype, value)
if not ok:
raise TypeError(
f"{where}: expected {self.dtype.value}, got {type(value).__name__}"
)
def coerce(self, value: Any) -> Any:
"""Best-effort conversion of an external value into this port's type.
Used where payloads arrive as text (HTTP query strings, MQTT), never on
the path between nodes — there a wrong type is an error, not a hint.
"""
if self.dtype is DType.FLOAT:
return float(value)
if self.dtype is DType.INT:
return int(value)
if self.dtype is DType.BOOL:
if isinstance(value, bool):
return value
return str(value).lower() in ("true", "1", "yes", "on")
if self.dtype is DType.STR:
return value if isinstance(value, str) else json.dumps(value)
if self.dtype in (DType.SERIES, DType.RECORD, DType.LIST, DType.ARTIFACT):
# A structured payload arriving as text is the same hint a numeric
# one is; the shape itself is still checked afterwards.
return json.loads(value) if isinstance(value, str) else value
return value
def __repr__(self) -> str:
return f"MessageSpec({self.name or self.port})"
def __hash__(self) -> int:
return hash((self.name, self.port))
def qualify(flow: str, name: str) -> str:
"""Resolve a message name against its flow namespace."""
if not name:
return ""
return name if "." in name else f"{flow}.{name}"
def flow_of(qualified: str) -> str:
"""The flow a qualified message name belongs to."""
return qualified.split(".", 1)[0]